diff --git a/.claude/skills/docs-sync/SKILL.md b/.claude/skills/docs-sync/SKILL.md index 8576639..b1479a9 100644 --- a/.claude/skills/docs-sync/SKILL.md +++ b/.claude/skills/docs-sync/SKILL.md @@ -6,7 +6,7 @@ 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. +The tests in `__tests__/docs-snippets/` run the server-side `Node.js` snippets of the Stream docs (the chat, activity feeds v3 and moderation Node sidebars, the video API sidebar and the platform sidebar: `SIDEBAR_FILES` in `sync/parse.ts`) 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. @@ -38,8 +38,7 @@ 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). +3. `$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. @@ -49,9 +48,9 @@ Before the sync check, make sure every server-side snippet on the tested pages h ### 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`. +- Pages: every page of the tested sidebars (`SIDEBAR_FILES` in `sync/parse.ts`, `$DOCS_DIR/_sidebars/`) under `PAGE_PREFIXES`, the same list as `findSidebarPages`. - 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, the stream-chat-js@9 fences (`JavaScript (stream-chat-js@9)`, `Node.js (stream-chat-js@9)`), `Node.js` fences inside a `` block, and comment-only fences. +- Out of scope: `React` and `React Native` fences, the stream-chat-js@9 fences (`JavaScript (stream-chat-js@9)`, `Node.js (stream-chat-js@9)`), `Node.js` fences inside a `` block, fences listed in `EXCLUDED_FENCES` (`sync/parse.ts`), 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: @@ -90,7 +89,7 @@ It runs without network and reports four kinds of failure. Collect them all, gro | 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). | +| `snippets without a test region` | A `Node.js` fence on a tested sidebar page (`SIDEBAR_FILES`) 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`, `Node.js` or their stream-chat-js@9 versions. | | `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. | @@ -114,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 / 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. + - **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): report it with the evidence (see "Report"). `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"). - **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. @@ -124,17 +123,16 @@ Label it `Node.js` for `@stream-io/node-sdk` (server-side) code, `JavaScript` fo - **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: +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`, `activity-feeds/v3-latest/_default/05-activities/01-activities.md` → `__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/01-activities.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. +- **Another SDK's code in a `Node.js` fence** (e.g. Feeds v2 `getstream` client code: `client.flagUser`, `client.user(id).flag`, `ctx.alice.feed(...)`): it isn't `@stream-io/node-sdk` code and isn't ours to update. Don't rewrite or relabel it: add the fence to `EXCLUDED_FENCES` in `sync/parse.ts` with a reason, and report it. - **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`. Its `Node.js (stream-chat-js@9)` partner, if any, becomes `JavaScript (stream-chat-js@9)`, code unchanged. 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`. @@ -155,7 +153,7 @@ Only when the docs snippet itself doesn't work (wrong method name or signature, 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. +Docs bugs you leave unfixed (prose, non-`Node.js` fences, or waiting for a decision) go in the report (see "Report"). ## 6. Gates @@ -190,6 +188,7 @@ 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 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. +7. **API spec / generator and node SDK issues**: where the snippet is correct and the API accepts it, but the spec, the generated code or the hand-written node SDK code is wrong. Each with the fence, the type or runtime error, what the API actually accepts or returns, and how the test handles it (`it.skip`, local cast, or a `yarn types:docs` error that stays until the SDK is fixed). +8. **Open docs bugs**: docs bugs left unfixed (prose, non-`Node.js` fences, or waiting for a decision), each with the page, the problem and the evidence. +9. **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. +10. **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 2b977c0..2f38809 100644 --- a/__tests__/docs-snippets/AGENTS.md +++ b/__tests__/docs-snippets/AGENTS.md @@ -11,16 +11,16 @@ New entries are added only after the user approves them (agents propose them in - `@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"`. +- **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, rule names, feed moderation template names, evaluation set names and custom view (queue) names, external storage names, feeds membership level ids, feeds collection names / ids, predefined filters, push providers, call types, calls, feed groups, feeds, feed views. 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. +- **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, rules, feed moderation templates, evaluation sets and custom views, external storages, membership levels, feeds collections, predefined filters, push providers, polls, call types, calls, feed groups, feeds, feed views). 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; activities, comments and reactions with their feed. 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, calls and feeds are found by listing every one 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). +- `helpers/call-types.ts`: `createCallType(client, cleanup)` creates a `uniqueId` call type, waits until it reads back, and registers it on `cleanup.callTypes`; `trackCallTypeCreates(client, cleanup)` does the same for fences that create the type themselves (spy, call before the region); `retryOnCallTypeMissing(fn)` retries calls into a new type (successful `getCallType` reads don't prove every node knows it: `getOrCreate` can still fail with code 16 "call type does not exist", wrap it in a spy inside regions). A call type with calls can't be deleted (code 4 "multiple calls of that type exist"): `Cleanup` hard-deletes `cleanup.calls` (waiting for the delete task) before `cleanup.callTypes`. `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`, `waitForTeamIdSupport` (multi-tenancy toggles) `waitForReviewItem` (review queue item of an entity once its flags are there), `deleteFeedModerationTemplate` (the generated `v2DeleteTemplate()` can't send the name) and `saveAndRestoreEventHooks(client, cleanup)` (saves `event_hooks`, registers the restore, returns `{ original, restore }`; `waitForEventHooks`, `withoutTimestamps`). `helpers/feeds.ts`: `retryOnFeedGroupMissing`, `addActivityOnce`, `copyFeedGroup(client, cleanup, builtInId)` (a `uniqueId` group with a built-in group's settings, on `cleanup.feedGroups`), `notificationTypes` / `waitForNotificationTypes` (a user's built-in `notification` feed). `helpers/uploads.ts`: upload fixtures (`JPEG_BASE64` / `jpeg`, `MINIMAL_PDF`) and `ignoreMissingFile` (swallows the 404 of deleting a gone upload). - 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. @@ -46,6 +46,7 @@ New entries are added only after the user approves them (agents propose them in - `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. +- A fence where one mid-fence call can't run on the test app (e.g. "there is no active session") can still run in a normal `it`: spy on that generated method (`client.video.*`; `StreamCall` methods go through it) with a `mockImplementation` that records the error and returns `undefined as never`, assert the error and the other calls. Bind the original before `vi.spyOn` (binding after it binds the spy and recurses). - 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. @@ -57,6 +58,7 @@ New entries are added only after the user approves them (agents propose them in - 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'`). +- A COPY value can be a backtick literal (``id=`my-story` ``) to match a fence's template literal. Don't name a COPY variable after an identifier the fence also uses as an object (`john` with `john.id`): use `johnId` and declare `const john = { id: johnId }`. - 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`. @@ -67,7 +69,7 @@ New entries are added only after the user approves them (agents propose them in ### 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)`. +- To test a fence that changes a channel type setting (e.g. `reminders`, `grants`), create the `uniqueId` type with the required fields only, assert the default, then run the fence. 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: {} }`. @@ -85,7 +87,8 @@ 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 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. +- A fence calling a method the node SDK doesn't have (left out of the spec, TS2339) also fails eslint's `no-unsafe-*` rules, which would break `yarn lint` in CI: put it in `it.skip('BLOCKED: SDK issue: ...')` and type the missing method with a narrow local cast on the client (`getServerClient() as StreamClient & { moderation: { method: (req: {...}) => Promise<{...}> } }`), with a comment, and report it. The region stays as the docs have it. +- A correct fence that the SDK types reject can't be parked with `it.skip`: skipped bodies are still typechecked. Keep the region anyway (in a running `it` if it works at runtime, else `it.skip('BLOCKED: SDK issue: ...')`) so coverage passes, and report it as an API spec / generator issue: `yarn types:docs` reports it until the SDK is fixed. The generator only emits `| null` for fields the spec marks `nullable` (e.g. `grants`, `revoke_tokens_issued_before`); a `null` on any other field fails TS2322 (e.g. `activity_metrics_config` on `updateApp`). - `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. @@ -100,6 +103,7 @@ New entries are added only after the user approves them (agents propose them in - 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. +- A `Node.js` fence with another SDK's code (Feeds v2 `getstream` client code, e.g. on the moderation Feeds v2 quick start) isn't ours to update: list it in `EXCLUDED_FENCES` (`sync/parse.ts`) with a reason instead of rewriting or relabelling it. - 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. @@ -128,9 +132,34 @@ New entries are added only after the user approves them (agents propose them in - 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". +- Video calls: create them with `uniqueId` ids and push the cid to `cleanup.calls` (hard deleted, "not found" ignored). Call types: `cleanup.callTypes` (see `helpers/call-types.ts`). +- Feeds v3 works on the test app (feed groups `user`, `timeline`, `notification`, ...): create feeds with `uniqueId` ids (`client.feeds.feed(group, id).getOrCreate({ user_id })`) and push the fid (`group:id`) to `cleanup.feeds` (hard deleted, "not found" / "can't be found" ignored, so a snippet may delete it first). 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 (hyphens are accepted) and push it to `cleanup.feedGroups` (deleted after `cleanup.feeds`). Calls into a new group can fail with code 16 "Feed group with ID ... not found" for a while (usually 1–2s): `retryOnFeedGroupMissing(client, ['getOrCreateFeed', 'follow', ...])` (`helpers/feeds.ts`) spies on the generated methods and retries them (`StreamFeed` methods go through `client.feeds`). `addActivity` into a new group can answer "not found" after storing the activity, so a blind retry adds it twice: in setup use `addActivityOnce(client, request)` (explicit id, retries only while `getActivity` doesn't find it). `updateFeedGroup` changes take a few seconds to reach reads: wait for several reads in a row to agree. An `updateFeedGroup` fence that leaves fields out clears them, including `aggregation` and `push_notification.push_types`. Feed group `ranking.type` accepts only `expression` and `interest` (code 4); recency is the default when `ranking` is omitted. `updateFeedGroup({ id })` with no other fields clears `custom` (updates replace every field). `deleteFeedGroup` without `hard_delete` soft-deletes (listed only with `include_soft_deleted: true`). +- Feed views are app-wide and eventually consistent: `uniqueId` ids and `cleanup.add(() => client.feeds.deleteFeedView({ id }))` (ignore "not found"). `getFeedView` and `getOrCreate({ view })` answer code 16 "... not found" for 1–3s after a create (`retryOnFeedGroupMissing` / `isFeedGroupMissing` match it), `listFeedViews` lags ~4s, `ranking.type: "recency"` is rejected, `updateFeedView({ id })` replaces every field. To read a spy that `retryOnFeedGroupMissing` installed, call `vi.spyOn(client.feeds, 'method')` again (returns the existing mock); `vi.mocked(client.feeds.method)` fails `unbound-method`. +- `updateApp({ max_aggregated_activities_length })` returns 200 but `getApp` keeps 0 and the limit isn't applied (2026-10-16); values over 100 are rejected (code 4). +- Feed ranking: ranked results settle within ~1–3s; make test popularity the reverse of recency so a passing order proves the ranking. Popularity = reactions (one per type per user: use several types) + shares × 3. Ranking expressions are validated on create (unknown variables such as `externals`, fields without defaults, non-map arguments to map functions, `min_popularity` < 1 for the popular selector). `preference_score` is 0 until the user gives `activityFeedback`. A group created without `activity_selectors` defaults to `current_feed`. Map key order in responses (`ranking.functions`) isn't stable: sort before comparing. A duplicate `createFeedGroup` id (or a built-in id like `timeline`) fails with "already exists": a docs bug. +- Popularity = reactions + comments ×2 + bookmarks ×3 + shares ×3: `addCommentsBatch` from one user is the cheapest way to a high popularity (35 comments = 70). A proximity selector group needs a `near` / `within_bounds` filter on read (code 4 for a user without a location); give activities `location: { lat, lng }`. To prove a `feed_groups` scope exclude, give the excluded `uniqueId` group a popular activity and show it before and gone after the update. In `beforeAll` (before a `beforeEach` spy exists), wrap calls into a new group in `retry(..., { retryIf: isFeedGroupMissing })`. +- `DOCS_SYNC_WRITE` re-indents template-literal content by the region's indentation: indent it 4 extra spaces in the test. +- A file-wide `beforeEach(() => retryOnFeedGroupMissing(...))` covers flaky first reads of new groups; inside a region read results by calling `vi.spyOn` again plus `mockClear()`. +- Activity processors (`text_interest_tags`): topics aren't in the `addActivity` response; they appear on `getActivity` after ~2–10s. On a new group, about 1 activity in 4 never gets topics (likely a node with a stale group config): wait for several `getFeedGroup` reads in a row before posting, `retry(getActivity)`, and fall back to posting the same text again. The built-in `user` group has no `activity_processors`. `allowed_tags` read back sorted. +- Feeds `getOrCreate({ data })` on an existing feed ignores `data` (`created: false`): a fence that creates the same feed twice with different data is a docs bug (give the second its own id). Server-side `createFeedsBatch` requires `created_by_id` on every feed (code 4). After a hard delete task completes the same fid can be re-created (`created: true`, empty); before that `getOrCreate` answers code 16 "has been deleted". `deleteFeedsBatch` on deleted feeds fails with code 16 "can't be found". +- Fixed ids built inside a template literal (`` `feed_${index}` ``) can't be COPY-mapped: a pass-through spy that only prefixes the id field with a `uniqueId` keeps the region as written and isolated. +- `queryFeeds` full-text filters (`$q` on name, description, `created_by.name`) match single words and find new feeds within ~1s: put a run-unique word into names to scope an undefined search variable exactly; for fixed docs literals (`"Sports"`), add a public matching feed and a private one and assert inclusion / exclusion. `queryFeeds` takes no `user_id`. +- `changeFeedVisibility` (`StreamFeed.changeFeedVisibility`, no `user_id`) is async: a second change right after the first can fail with code 4 "a visibility change is already in progress for this feed" (retry in a spy), and until it applies follows behave like the old visibility. Tag-visibility activities come back `preview: true` for viewers without the tag and `false` for the owner. +- Follows: a fence that follows the same source/target pair twice is a docs bug (code 4 "... already exists in accepted state"): give the later call its own target. `StreamFeed` (`client.feeds.feed(...)`) has no `follow` / `unfollow`: a fence calling `feed.follow("group:id", {...})` is client JS SDK code (label it `JavaScript`). `UpdateFollowRequest` uses `follower_role` (unknown fields like `role` are dropped by the generated method). +- Feeds membership levels are app-wide: `uniqueId` ids on `cleanup.membershipLevels` (deleted after `cleanup.feeds`, retried while code 17 "... currently in use by feed members"). `updateMembershipLevel` returns 500 (code -1, empty message) for any level that was ever assigned to a member, also after the member is removed (2026-10-16): show an update works on a never-assigned level. Feed member roles aren't validated; `queryFeedMembers` without a filter returns accepted members only. A fence that accepts and then rejects the same invite is a docs bug ("feed member is not in pending state"). +- Feed auto-creation on `addActivity` / `upsertActivities` needs an existing user whose id equals the feed id, in any group (otherwise code 113 "Missing feed ... can't be created: user not found"): for fixed docs fids create a `uniqueId` user per feed id and use `group:`. Server-side `deleteActivity` takes no `user_id` (TS2353); `deleteActivities` takes `ids` (not `id`). Soft-deleting an already soft-deleted activity in `deleteActivities` succeeds. +- `queryActivities`: `hidden: { $eq: true }` matches nothing, use `hidden: true` (2026-10-16). For regions with app-wide filters (`{}`, `activity_type: "post"`), first `retry` a setup query scoped by `id` / `user_id` until the test's activity is indexed, then assert it's included (first for `created_at` desc). A moderation-removed activity (for `include_moderated_activities`): a `uniqueId` blocklist + config with a `remove` rule, `feed.update({ custom: { moderation_policy: key } })`, then `retry` `addActivity({ force_moderation: true })` until `activity.moderation.action === 'remove'` (~5s). +- `updateApp({ activity_metrics_config })` merges into the stored config, so `{}` changes nothing (docs say it resets). Restore an absent original with a raw `client.apiClient.sendRequest('PATCH', '/api/v2/app', undefined, undefined, { activity_metrics_config: null })` (the type has no `| null`) and `waitForAppSetting(client, 'activity_metrics_config', undefined)`. Activity `metrics` sync every ~5 minutes, so assert `trackActivityMetrics` results (`allowed`, `error`) instead; use up a limit with one large `delta` to hit the rate-limited branch. Server-side `addCommentReaction` / `queryCommentReactions` take `id` (not `comment_id`). +- Feeds bookmarks are identified by (user, activity, folder): to target a folderless bookmark in `updateBookmark` / `deleteBookmark`, leave out `folder_id`. Deleting a folder deletes its bookmarks; folder update / delete need no `user_id`. A cheap setup folder: `addBookmark({ activity_id, user_id, new_folder: { name } })`. A `next` cursor reused on a query with a different filter doesn't error but silently skips results: a docs bug. +- Server-side `updateComment` needs `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. `restrict_replies: "people_i_follow"` rejects a non-follower's `addComment` with code 5. +- A COPY value can be an unquoted expression (`fixturesDir=import.meta.dirname`), so a fence that builds paths from `import.meta.dirname` can read fixtures from a tmpdir. A Node.js fence using `__dirname` together with `import` and top-level await can't run (it's an ES module): the docs fix is `import.meta.dirname`. +- Feeds URL enrichment: `https://example.com/...` has no OG data (`getOG` code 4 "could not find any opengraph data"), so COPY-map the docs URL (or whole text) to `https://getstream.io`; with `async_url_enrich_enabled` (on for the test app) attachments reach `getActivity` / `getComment` within ~1s. `getActivity` takes `{ id, comment_limit }` and returns the latest comments in `activity.comments` (no `latest_replies`); fences ported from client code (`getActivity(id, { with_recent_replies })`, `getOrCreateFeed(group, id, req)`) are docs bugs. `AddActivityRequest.expires_at` is an ISO `string`. Expired pins are left out of `queryPinnedActivities` without `include_expired_activities: true`. +- Feeds polls: a vote needs the poll in an activity first (`addActivity({ poll_id, ... })` by the poll's creator), then `client.feeds.castPollVote({ activity_id, poll_id, user_id, vote })`. `updatePollPartial` with `unset` nested inside `set` returns 200 and stores `unset` as a custom property (nothing unset): a docs bug; `unset` goes at the top level. +- Feeds collections are app-level and don't go away with their user or feed: `uniqueId` names and ids, refs (`name:id`) on `cleanup.collections`. `createCollections` on an existing ref fails with code 4 "One or more collections already exist"; `deleteCollections` on a missing ref succeeds. `StreamFeed` has no `addActivity`: a fence calling `feed.addActivity(...)` is client code, the node fix is `client.feeds.addActivity({ feeds: [fid], ..., user_id })`. An `updateFeedGroup` response omits `enable_push: false` (`push_notification` reads `{}`). +- Stories: `addActivity` accepts an `expires_at` in the past (an expired activity at once). Expired activities drop out of feed reads but `queryActivities` with an `expires_at` filter still finds them. +- To make an unread notification: `getOrCreate` the target's `notification` feed, then `addActivity` from another `uniqueId` user with `feeds: ['notification:']`; `notification_status.unread` counts it at once, and `markActivity({ mark_all_read: true, user_id })` resets it. +- Undeclared assignments in a fence (`client = new StreamClient(...)` twice) fail TS2304: the docs fix is `const client` plus a new name for the second (`clientWithTimeout`), not `let` (`no-useless-assignment`). - `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. @@ -223,6 +252,52 @@ New entries are added only after the user approves them (agents propose them in - 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). - `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]`. +- Moderation configs: use `uniqueId` keys (a channel-level chat policy is `chat:messaging:`) and `cleanup.add(() => client.moderation.deleteConfig({ key }))`. COPY-map app-wide keys (`"feeds"`, `"chat:messaging"`) and blocklist names (`profanity_en` doesn't exist on the test app, only `profanity` and `url_detection_v1`) to test-owned ones. +- Right after `createBlockList`, `upsertConfig` referencing it can fail with code 4 "Blocklist not found" for ~5s: retry on that message (in a spy when it happens inside a region). A new policy can take a moment to apply: before asserting a block, `retry` a setup `check` / message until it is blocked. +- `ai_text_config` rule labels are validated (code 4 "invalid ai_text config rule label: X"). Accepted (2026-10-16): SPAM, SEXUAL_HARASSMENT, INSULT, HATRED, THREAT, MORAL_HARASSMENT, TROLLING, SCAM, SELF_HARM, DOXXING, PII, PLATFORM_BYPASS, BODY_SHAMING; `HARASSMENT`, `TOXIC`, `HATE_SPEECH`, `VIOLENCE` are rejected. +- Feeds `addActivity` with `force_moderation` returns `moderation: { action, original_text, blocklist_matched, blocklists_matched }` (no `status` / `recommended_action`). A banned user's `addActivity` fails with code 17 "... Your account is currently banned." +- Call moderation rules (`upsertModerationRule` with `rule_type: "call"`): `keyframe_rule_params` and `closed_caption_rule_params` require `time_window` (15s, 30s, 1m, 5m, 30m, 1h, 24h, 7d, 30d), and `keyframe_rule_params.min_confidence` must be 0.0–1.0. Rules have no client-chosen `id` (TS2353; the API assigns a UUID): give them `uniqueId` names and delete them by the id from the upsert response (spy on `upsertModerationRule`). +- LLM text engine (`llm_config`): on the test app it never classifies (a direct threat comes back `keep`, no review queue item; probed 2026-10-20), so assert the stored config and `status: "complete"`, not the docs verdict. `llm_config.context_custom_keys` / `custom_keys_context_description` are accepted and returned by the API but missing from the spec and the generated `LLMConfig` (TS2353): type them with interface merging on `src/gen/models` in the test file (see `pages/moderation/03-moderation-engines/01-ai-llm-text-harm-detection-engine.test.ts`). +- `upsertModerationRule` returns conditions with defaults filled in (`text_rule_params` gets `threshold: 0`, `time_window: ""`): compare them with `toMatchObject`, not `toEqual`. +- `moderation.check` image checks return `status: "partial"` plus a `task_id` (async analysis) while a call rule with a keyframe condition exists on the app, `"complete"` otherwise: accept both. +- `CheckRequest.content_published_at` is a `Date`: a fence passing `new Date().toISOString()` fails TS2322, and the docs fix is `new Date()`. +- `upsertModerationRule` requires `rule_type` (user, content, call, flood); its `config_keys` aren't validated (a nonexistent `chat:messaging:` is accepted), and condition harm labels (`text_content_params.harm_labels`) aren't validated either, unlike `ai_text_config` labels. `getModerationRule` on a missing id rejects with code -1 and an empty message: prove a delete with `queryModerationRules({ filter: { id } })` returning `[]`. +- To prove a moderation config applies without a channel: `client.moderation.check({ entity_type: 'stream:chat:v1:message', entity_id: uniqueId(...), entity_creator_id, config_key, moderation_payload: { texts: [...] } })` against the test-owned key, asserting `recommended_action` (`retry` the first check). `deleteBlockList` succeeds while a config still references the blocklist. +- Moderation evaluation sets (`createPolicyTestSet`) get server-assigned ids and unique-per-app names (code 4 "... already exists"): use `uniqueId` names (the leak check matches them) and delete them by id. Runs finish within about a second: `retry` `getPolicyTestRun` until `status` is terminal. Seeded sets sample app-wide moderated content whatever `config_key` is passed. +- Team moderation: `upsertConfig({ team })` needs multi-tenancy on (code 4 "Team moderation configuration is not supported in single tenant mode"); `getConfig` / `deleteConfig` need the `team` too (without it: code 16). Team blocklists (`createBlockList({ team })`, works with multi-tenancy off) need `team` on get / update / delete, and `listBlockLists({ team })` is off on the test app (code 4 "Multi-tenant blocklist is not enabled for this app"). The leak check can't see either: register deletes with the team inside the multi-tenancy `describe`. Review queue `teams` filters match items whose entity creator is in the team. +- `queryModerationConfigs({ limit })` returns no `next` even when more configs exist: don't rely on its pagination cursor. +- `flood_config` works on `moderation.check`: N identical checks from one user (threshold N) give `keep` ×(N-1), then `flag`. +- `customCheck` responses have `item.flags: []`; the flags reach the review queue item about a second later (`waitForReviewItem(client, entityId, { flags })`). +- Server-side `client.queryBannedUsers` takes `{ payload: { filter_conditions, sort, limit, exclude_expired_bans } }`: top-level fields are a type error and never sent. `exclude_expired_bans` can be proven with a `timeout: 1` (minute) ban and a `retry` until it has expired (give that test ~150s). +- Ban and mute `expires` is exactly `created_at` + timeout, and the API clock can be a few ms ahead of the local one: compare `expires - created_at`, not against `Date.now()`. +- `client.moderation.bypass` takes `{ target_user_id, enabled }` (read back as `bypass_moderation` on the user). Block users with `client.blockUsers({ blocked_user_id, user_id })` and check `getBlockedUsers({ user_id })`. +- Moderation custom views (`createQueue`) have server-assigned ids and are capped at 10 per app: give them `uniqueId` names and delete them by id (an already deleted view fails with code 16; `deleteQueue` returns `queue: null`). `listQueues({ user_id })` re-creates a deleted user, so call it before the users are deleted. For a `queryReviewQueue({ filter: { reviewed: false } })` snippet, add a negative control: flag a newer item, `submitAction({ action_type: 'mark_reviewed', item_id, user_id })` it and assert it is absent. +- `submitAction` ban with `item_id`: the channel comes from the flagged message and only `ban: { channel_ban_only: true }` makes it a channel ban; unknown ban fields (e.g. `channel_cids`) are silently ignored and give an app-wide ban. Check bans with `queryBannedUsers` (`bans[i].channel?.cid`). To prove `ban_from_future_channels`, create a new channel by the original channel's creator with the target as member and check `members[].banned` from `getOrCreate({ state: true })`. `getReviewQueueItem(...).item.latest_moderator_action` reads back every `submitAction` (`custom:` for custom actions). Use a fresh `uniqueId` creator per flagged item when tests ban users. +- Appeals: content can only be appealed after a moderator action (e.g. `delete_message`) or a policy block, not on an unreviewed flag (code 4 "... flagged but not yet reviewed by a moderator"); ban appeals need an active ban (app-wide and channel bans are appealed separately, with `channel_cid`). For blocked content to `unblock`, use a channel-level blocklist policy plus `force_moderation: true` (`submitAction` `block` is rejected: "invalid action type: block"). Accepting via `unban` / `unblock` reads back `status: 'accepted'` with the `decision_reason`. +- `queryModerationLogs` rejects unknown filter fields (code 4 "unrecognized field"): allowed are id, type, created_at, date_range, target_user_id, entity_id, reporter_id, reporter_type, moderation_category, ai_providers, review_queue_item_id, reason, resource_type, label, config_key. +- To probe an endpoint the node SDK doesn't have: `client.moderation.apiClient.sendRequest(method, path, undefined, query, body)`. +- `upsertConfig` replaces the whole config: every engine config the request leaves out is reset to null. A fence that upserts an existing key with a partial config wipes the rest of the policy. `rule_builder_config` (only `async`, `rules`) is neither stored nor returned; moderation rules apply through their `config_keys` without an enable step. +- A fence using `require(...)` gets `any` and fails eslint (`no-require-imports`, `no-unsafe-*`): the docs fix is the equivalent ESM `import`. +- Video: `call.create` / `getOrCreate` on an existing call ignores `data.members` and `data.custom` (`created: false`, call unchanged): a fence that creates the same call twice with different data is a docs bug, so give the second create its own call id. In `GetOrCreateCallResponse.members` a member added without `role` has `role` undefined (`queryMembers` after `updateCallMembers` returns `'user'`). +- A client fence with empty credentials (`const apiKey = "";`) can be a module-level region with `COPY: streamApiKey=""` mapped to `process.env.STREAM_API_KEY`. The SDK's default timeout (3000) can be asserted with `vi.spyOn(AbortSignal, 'timeout')`. +- Video `queryCalls`: a `next` / `prev` request must repeat the original `sort` (without it: code 4 "cannot specify sort and next/prev at the same time", a misleading message). Sorting by `starts_at` returns an empty next page when the values are null. `filter_conditions: { backstage: { $eq: false } }` only matches calls that went live: create a `livestream` call and `goLive()` it (`default` has backstage off: "backstage is not enabled for call types default"). +- `StreamCall.queryMembers` takes the request directly (no `payload` wrapper, unlike `queryUsers` / `queryBannedUsers`). `StreamCall` has no `settings`: read them with `call.get()`. `UpdateCallRequest` has `settings_override` at the top level (no `data`). +- Video call type grants for an unknown role fail with code 4 `cannot use unknown role "x" in permission grants`; a just-created role can be unknown briefly (retry on "unknown role"). `updateCallType({ grants: {} })` leaves the grants unchanged. The node SDK exports `OwnCapability` (docs' `VideoOwnCapability` is a docs bug). Built-in call types: `audio_room`, `default`, `development`, `livestream`. +- Video `goLive({ start_hls, start_recording, start_closed_caption, start_transcription, ... })` on a call with no participants succeeds (storage names aren't validated) but starts nothing (`recording` / `transcribing` / `captioning` false, `egress.hls` null): assert `backstage`, not the processes. `updateCallType({ grants: { : [...] } })` replaces that role's list; a new call type has no `host` grants. +- `StreamCall.createSRTCredentials(userId)` is local and needs `call.data` from an awaited `get()` / `getOrCreate()` ("Object is not initialized"). Parse the address with `new URL(address)`: the last `streamid` segment is the user's JWT and `passphrase` its signature (don't compare with a fresh token: `iat` can differ). +- Video egress (`startHLSBroadcasting`, `startRTMPBroadcasts`, recording, ...) on a call nobody joined fails with code 4 "cannot broadcast inactive call", and the stop calls then fail too ("call egress is not running" code 4; "no active rtmp broadcasts ..." code 4 / 16): those fences are ⛔ BLOCKED with the exact error. A fresh call's `egress` is `{ broadcasting: false, hls: null, rtmps: [] }`. +- **Video call sessions without WebRTC**: `joinCallAsUser(call, userId)` (`helpers/video.ts`) joins over REST (`POST /video/call/{type}/{id}/join` with a user token) and starts a session. After it, `startRecording` / `startTranscription` / `startClosedCaptions` / `startHLSBroadcasting` / `startFrameRecording` and their stops work (2026-10-16), so egress fences aren't BLOCKED: join a `uniqueId` member first. `postAsVideoUser(userId, path, body)` calls other client-side video endpoints (e.g. `feedback`: needs a joined session, `sdk_version` ≥ 2 chars; server-side `collectUserFeedback` fails with "user_id is missing"). Feedback goes away with the call or user. `getActiveCallsStatus` is limited to 6 requests per minute (`retryOnRateLimit` spy). +- Video egress state lags behind the start: `recording` turns true ~6–9s after `startRecording`, `transcribing` / `captioning` after a few seconds; `egress._recording.status` / `frame_recording.status` are `starting` at once. Read state with `retry` (inside a spy on the start method if the fence stops right after). Register a `cleanup.add` stop (ignoring errors) for every egress a test starts. `startRTMPBroadcasts` to a placeholder target answers 201 but `egress.rtmps` stays `[]`; transcription to a placeholder S3 storage is accepted but `transcribing` never turns true. Only the creator (or a host) can REST-join a `livestream` call in backstage (a member gets code 17 JoinBackstage). +- To test `deleteRecording`: record a composite for ~10s on a joined call, `retry` until `listRecordings` returns it (~15s after the stop), and COPY-map `session_id` / `filename`. +- A custom role granted in a call type can't be deleted until that type is gone (code 4 "... present in permission grants of scopes [video:]"), and `cleanup.callTypes` runs after `cleanup.add` steps: delete the type inside the role's cleanup step. To stack a retry on a shared spy helper, wrap `spy.getMockImplementation()` (spying the same method again recurses). +- Video participant stats (`queryCallParticipantSessions`, `getCallParticipantSessionMetrics`, `queryCallStats` reports) need real SFU participants: a REST join leaves `call.session.participants` empty, and the endpoints answer code 16 "... not found" / "no stats available" indefinitely. Run single-call fences against a joined session with a spy that records the 404 (the request is still validated first: time window ≤ 30 min, `limit` ≤ 100, cursor, session id); `it.skip('BLOCKED: ...')` loops that page through results. +- Video `call.delete()` (soft) makes the call unreachable: a later hard delete fails with code 16 "Can't find call with id ..." (`cleanup.calls` ignores it) and the leak check can't list it, so a soft-deleted call stays on the app. `call.blockUser` / `kickUser` / `muteUsers` / `updateUserPermissions` work on a call with a REST-joined member. +- Video recording (`startRecording`) on a call nobody joined fails with code 4 "there is no active session"; stops fail with "call egress is not running" (composite) or `recording "call_raw_recorder:" is not running` (individual / raw). `StreamCall.startRecording` / `stopRecording` take `{ recording_type, ...request }`, not a positional string. `recording` settings (call `settings_override` or call type) with `mode` `available` / `auto-on` and `audio_only` false need `quality` (code 4 "recording quality is required when audio_only is false and recording is enabled"), even when the call already has one. +- External storages are app-wide (max 10 per app; the built-in `stream-s3` is always listed): `uniqueId` names, `cleanup.add(() => deleteExternalStorageIfExists(client, name))` (`helpers/server.ts`: a delete right after the create can fail with code 4 "... does not exist" while the storage is still listed). Placeholder S3 / ABS credentials are accepted on create and only fail `checkExternalStorage` (code 4 "InvalidAccessKeyId ..."); GCS credentials are validated on create (code 4 "invalid gcs credentials"). `startFrameRecording` / `startTranscription` on a call nobody joined fail with code 4 "there is no active session"; `deleteRecording` of an unknown file fails with code 16 "recording doesn't exist". +- The node SDK has no `Video...Enum` objects (TS2304): the docs fix is the string literal (`"1080p"`, `"spotlight"`). `updateCallType` takes `{ name, ...request }`, not positional args. +- COPY on a name the fence declares (`const callId = "my-call"`) drops that declaration on write-back: COPY a different name and keep `const callId = myCallId;` in the region. +- `moderation.ban` `timeout` is in minutes (prove it with `expires - created_at`). `ai_image_config.ocr_rules` take text harm labels (SPAM, INSULT, HATRED, SEXUAL_HARASSMENT, THREAT), not image labels (code 4 "invalid ai_image ocr rule label: X"). Blocklist words are at most 40 characters: don't build them from a `uniqueId`. `getApp().app.event_hooks` order isn't stable: match hooks by a field, not by index. +- A bare expression statement in a fence (`client.moderation;`) fails `no-unused-expressions`: the docs fix is to assign it. `${e}` on an unknown catch value fails `restrict-template-expressions`: the docs fix is `${String(e)}`. ### Server-side vs client-side diff --git a/__tests__/docs-snippets/README.md b/__tests__/docs-snippets/README.md index 3d75411..d4e2dcf 100644 --- a/__tests__/docs-snippets/README.md +++ b/__tests__/docs-snippets/README.md @@ -1,9 +1,15 @@ # 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`) 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 +Typed tests that run the server-side Node.js snippets of the Stream docs against a real Stream app: the pages of +these sidebars in `getstream.io/content/docs/_sidebars/` (`SIDEBAR_FILES` in `sync/parse.ts`): + +- chat: `[chat][node].json` +- activity feeds (v3): `[activity-feeds][node][v3].json` +- moderation: `[moderation][node].json` +- video: `[video][api].json` (the server-side API docs) +- platform: `[platform][default].json` + +Only fences labelled `Node.js` are in scope (a fence label, or the `label` of the enclosing `` on the video pages) (docs fences must always have a label): they are fixed when they are wrong. `JavaScript`, `React` and `React Native` fences (client-side code), the stream-chat-js@9 fences (`JavaScript (stream-chat-js@9)`, `Node.js (stream-chat-js@9)`) and `Node.js` fences inside a `` block (reference implementations without the SDK) are out of scope and are neither tested nor changed. @@ -39,6 +45,9 @@ check that notifications or webhook events arrive. ``` pages/chat//.test.ts # chat Node.js snippets, run with the server client +pages/activity-feeds/v3-latest//.test.ts # feeds v3 Node.js snippets +pages/moderation//.test.ts # moderation Node.js snippets +pages/video/api//.test.ts # video API Node.js snippets 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 @@ -47,13 +56,14 @@ 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 spec / generator and node SDK 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. `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 +`chat/node/01-quick_start/...` to `pages/chat/node/01-quick_start/...`, +`activity-feeds/v3-latest/_default/05-activities/01-activities.md` to `pages/activity-feeds/v3-latest/05-activities/01-activities.test.ts`, +`video/api/01-basics/03-calls.md` to `pages/video/api/01-basics/03-calls.test.ts` and `platform/02-identity-and-access/02-users.md` to `pages/platform/02-identity-and-access/02-users.test.ts`. ## Conventions @@ -75,14 +85,14 @@ await channel.getOrCreate({ data: { created_by_id: creatorId } }); ``` - **Fence key**: - - `docs` is the page path relative to `getstream.io/content/docs/` (`chat/...` or `platform/...`). + - `docs` is the page path relative to `getstream.io/content/docs/` (`chat/...`, `activity-feeds/...`, `moderation/...`, `video/...` or `platform/...`). - `heading` is the text of the nearest heading above the fence. - - `tab` is the fence `label` (or `unlabelled`). + - `tab` is the fence `label`, else the `label` of the enclosing `` (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 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` / stream-chat-js@9 fences and comment-only fences are ignored. +- **Coverage** (checked first): every `Node.js` fence (`js`/`javascript`/`ts`/`typescript`) on the pages of the tested sidebars (see the top of this file; `$fragment` entries are resolved from `_sidebars/fragments/`), whether or not the page has tests yet, must have a region (inside `it.skip` if it can't run). `JavaScript` / `React` / `React Native` / stream-chat-js@9 fences and comment-only fences are ignored. - **Labels**: every JS fence on those pages must be labelled `JavaScript`, `React`, `React Native`, `Node.js`, `JavaScript (stream-chat-js@9)` or `Node.js (stream-chat-js@9)`; 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. +- **Excluded pages and fences**: `EXCLUDED_PAGES` in `sync/parse.ts` lists sidebar pages that are deliberately not validated, with a reason. Both checks skip them. `EXCLUDED_FENCES` does the same for single `Node.js` fences (keyed by fence id) that hold another SDK's code, e.g. Feeds v2 `getstream` client code on the moderation Feeds v2 quick start: not ours to test or update. - **`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_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-todo.md b/__tests__/docs-snippets/docs-snippets-todo.md deleted file mode 100644 index e20a45c..0000000 --- a/__tests__/docs-snippets/docs-snippets-todo.md +++ /dev/null @@ -1,14 +0,0 @@ -# Docs snippet tests: todo - -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 spec / generator issues - -- **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). - -## Node SDK issues - -None open. diff --git a/__tests__/docs-snippets/global-setup.ts b/__tests__/docs-snippets/global-setup.ts index e4f52cc..76de494 100644 --- a/__tests__/docs-snippets/global-setup.ts +++ b/__tests__/docs-snippets/global-setup.ts @@ -23,8 +23,9 @@ import { 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. + // Campaigns, polls and moderation rules are listed as ` ()`, calls and feeds + // by cid / fid (`:`); 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; @@ -52,7 +53,7 @@ export default async function setup() { 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), + createdSince: new Date(startedAt - CLOCK_SKEW_MS), }); if (errors.length) { console.warn( diff --git a/__tests__/docs-snippets/helpers/call-types.ts b/__tests__/docs-snippets/helpers/call-types.ts new file mode 100644 index 0000000..e258938 --- /dev/null +++ b/__tests__/docs-snippets/helpers/call-types.ts @@ -0,0 +1,63 @@ +import { vi } from 'vitest'; +import type { StreamClient } from '../../../index'; +import type { Cleanup } from './cleanup'; +import { uniqueId } from './ids'; +import { retry } from './wait'; + +/** A new call type isn't known to every API node right away. */ +const isCallTypeMissing = (error: unknown) => + String(error).includes('does not exist'); + +/** Waits until a new call type can be read back several times in a row. */ +export const waitForCallType = async (client: StreamClient, name: string) => { + for (let i = 0; i < 5; i++) { + await retry(() => client.video.getCallType({ name }), { + retryIf: isCallTypeMissing, + }); + } +}; + +/** + * Creates a `uniqueId` call type, waits until it can be read back, and registers it on + * `cleanup.callTypes` (deleted after `cleanup.calls`). Use it for fences that change a + * built-in call type (COPY-map `"default"` to it). Calls into the new type can still + * fail with "call type does not exist" on some API nodes for a while: + * `retryOnCallTypeMissing`. + */ +export const createCallType = async ( + client: StreamClient, + cleanup: Cleanup, + name = uniqueId('call-type'), +) => { + await client.video.createCallType({ name }); + cleanup.callTypes.push(name); + await waitForCallType(client, name); + return name; +}; + +/** + * For fences that create a call type themselves: wraps `client.video.createCallType` + * so every type it creates is registered on `cleanup.callTypes` and awaited until it + * can be read back. Call before the region; restore with `vi.restoreAllMocks()`. + */ +export const trackCallTypeCreates = ( + client: StreamClient, + cleanup: Cleanup, +) => { + const original = client.video.createCallType.bind(client.video); + return vi + .spyOn(client.video, 'createCallType') + .mockImplementation(async (request) => { + const response = await original(request); + cleanup.callTypes.push(request.name); + await waitForCallType(client, request.name); + return response; + }); +}; + +/** Retries a call into a just-created call type while API nodes answer "does not exist". */ +export const retryOnCallTypeMissing = (fn: () => Promise) => + retry(fn, { + interval: 2000, + retryIf: (error) => String(error).includes('call type does not exist'), + }); diff --git a/__tests__/docs-snippets/helpers/cleanup.ts b/__tests__/docs-snippets/helpers/cleanup.ts index a0306da..fc40a6a 100644 --- a/__tests__/docs-snippets/helpers/cleanup.ts +++ b/__tests__/docs-snippets/helpers/cleanup.ts @@ -1,4 +1,5 @@ import type { StreamClient } from '../../../index'; +import { deleteMembershipLevel } from './feeds'; import { hardDeleteUsers } from './users'; import { CHANNEL_TYPE_PROPAGATION_MS, @@ -14,6 +15,7 @@ import { * ```ts * const cleanup = new Cleanup(getServerClient()); * cleanup.users.push(userId); + * cleanup.feeds.push(`user:${userId}`); * cleanup.add(() => serverClient.updateApp(originalSettings)); * afterAll(() => cleanup.run()); * ``` @@ -27,6 +29,18 @@ export class Cleanup { /** Channel cids (`type:id`). */ channels: string[] = []; channelTypes: string[] = []; + /** Video call cids (`type:id`), hard deleted. */ + calls: string[] = []; + /** Video call type names, deleted after `calls` (a type with calls can't be deleted). */ + callTypes: string[] = []; + /** Feed ids (`group:id`), hard deleted. */ + feeds: string[] = []; + /** Feed group ids, hard deleted after the feeds. */ + feedGroups: string[] = []; + /** Feeds collection refs (`name:id`); collections outlive their user and feed. */ + collections: string[] = []; + /** Feeds membership level ids, deleted after the feeds (a level in use can't be deleted). */ + membershipLevels: string[] = []; private custom: Array<() => Promise> = []; constructor(private serverClient: StreamClient) {} @@ -47,6 +61,28 @@ export class Cleanup { }; for (const fn of [...this.custom].reverse()) await attempt(fn); + for (const fid of this.feeds) await attempt(() => this.deleteFeed(fid)); + if (this.collections.length) { + await attempt(() => + this.serverClient.feeds.deleteCollections({ + collection_refs: this.collections, + }), + ); + } + for (const id of this.membershipLevels) { + await attempt(() => deleteMembershipLevel(this.serverClient, id)); + } + for (const id of this.feedGroups) { + await attempt(() => + ignoreNotFound( + this.serverClient.feeds.deleteFeedGroup({ id, hard_delete: true }), + ), + ); + } + for (const cid of this.calls) await attempt(() => this.deleteCall(cid)); + for (const name of this.callTypes) { + await attempt(() => this.deleteCallType(name)); + } 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) { @@ -64,6 +100,58 @@ export class Cleanup { } } + private async deleteCall(cid: string) { + const [type, id] = splitId(cid); + // Hard deletes run as a background task: wait for it, so the call type can be + // deleted afterwards. Right after a call type is created, some API nodes still + // answer "call type does not exist". + const startedAt = Date.now(); + const response = await ignoreNotFound( + retry( + () => this.serverClient.video.deleteCall({ type, id, hard: true }), + { + interval: 2000, + retryIf: (error) => + String(error).includes('call type does not exist') && + Date.now() - startedAt < 30000, + }, + ), + ); + if (response?.task_id) + await waitForTask(this.serverClient, response.task_id); + } + + private async deleteCallType(name: string) { + // "calls of that type exist": a hard call delete hasn't finished yet. + // "does not exist": a new type isn't known to every API node yet, or it is gone. + const startedAt = Date.now(); + try { + await retry(() => this.serverClient.video.deleteCallType({ name }), { + timeout: 60000, + interval: 2000, + retryIf: (error) => + String(error).includes('calls of that type exist') || + (String(error).includes('does not exist') && + Date.now() - startedAt < 30000), + }); + } catch (error) { + if (!String(error).includes('does not exist')) throw error; + } + } + + private async deleteFeed(fid: string) { + const [feed_group_id, feed_id] = splitId(fid); + await ignoreNotFound( + retryOnRateLimit(() => + this.serverClient.feeds.deleteFeed({ + feed_group_id, + feed_id, + hard_delete: true, + }), + ), + ); + } + 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 @@ -101,3 +189,26 @@ export class Cleanup { await waitForTask(this.serverClient, response.task_id); } } + +/** `type:id` -> [type, id] (ids may contain `:`). */ +const splitId = (cid: string): [string, string] => { + const index = cid.indexOf(':'); + return [cid.slice(0, index), cid.slice(index + 1)]; +}; + +/** Already gone (e.g. deleted by the snippet) counts as deleted. */ +const ignoreNotFound = async (promise: Promise) => { + try { + return await promise; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + !/not found|can't be found|can't find|does not exist|doesn't exist/i.test( + message, + ) + ) { + throw error; + } + return undefined; + } +}; diff --git a/__tests__/docs-snippets/helpers/feeds.ts b/__tests__/docs-snippets/helpers/feeds.ts new file mode 100644 index 0000000..e791346 --- /dev/null +++ b/__tests__/docs-snippets/helpers/feeds.ts @@ -0,0 +1,141 @@ +import { expect, vi } from 'vitest'; +import type { AddActivityRequest, StreamClient } from '../../../index'; +import type { Cleanup } from './cleanup'; +import { uniqueId } from './ids'; +import { retry } from './wait'; + +type FeedsClient = StreamClient['feeds']; +type AsyncRequestMethod = (request: never) => Promise; +/** Methods of `client.feeds` that take one request object and return a promise. */ +type FeedsMethod = { + [K in keyof FeedsClient]: FeedsClient[K] extends AsyncRequestMethod + ? K + : never; +}[keyof FeedsClient]; + +/** A new feed group isn't known to every API node right away (code 16 "... not found"). */ +export const isFeedGroupMissing = (error: unknown) => + String(error instanceof Error ? error.message : error).includes('not found'); + +/** + * For regions that call into a just-created feed group: spies on the given + * `client.feeds` methods so each call is retried while API nodes answer + * "Feed group with ID ... not found". `StreamFeed` methods go through `client.feeds`, + * so spy on the generated method (e.g. `getOrCreateFeed`, `follow`). Restore with + * `vi.restoreAllMocks()`. Avoid `addActivity` here (a retry can add it twice, see + * `addActivityOnce`). + */ +export const retryOnFeedGroupMissing = ( + client: StreamClient, + methods: FeedsMethod[], +) => { + // A plain record view of `client.feeds`: spying through the method-name union + // directly is too complex for TS (TS2590). + const feeds = client.feeds as unknown as Record; + for (const method of methods) { + const original = feeds[method].bind(client.feeds); + vi.spyOn(feeds, method).mockImplementation((request: never) => + retry(() => original(request), { retryIf: isFeedGroupMissing }), + ); + } +}; + +/** + * Adds an activity (setup only) into a feed whose group may be new. `addActivity` can + * answer "Feed group ... not found" after it already stored the activity, so a blind + * retry adds it twice: give it a `uniqueId` id and retry only while it isn't stored. + */ +export const addActivityOnce = async ( + client: StreamClient, + request: AddActivityRequest, +) => { + const id = request.id ?? uniqueId('activity'); + await retry( + async () => { + try { + await client.feeds.addActivity({ ...request, id }); + } catch (error) { + const stored = await client.feeds + .getActivity({ id }) + .then(() => true) + .catch(() => false); + if (!stored) throw error; + } + }, + { retryIf: isFeedGroupMissing }, + ); + return id; +}; + +/** + * Creates a `uniqueId` feed group with the settings of a built-in group (`notification`, + * `user`, `timeline`, ...), for fences that change a built-in group (built-in groups are + * app-wide). Registers it on `cleanup.feedGroups`; returns its id and the built-in group. + */ +export const copyFeedGroup = async ( + client: StreamClient, + cleanup: Cleanup, + builtInId: string, +) => { + const { feed_group: builtIn } = await client.feeds.getFeedGroup({ + id: builtInId, + }); + const id = uniqueId(builtInId); + cleanup.feedGroups.push(id); + await client.feeds.createFeedGroup({ + id, + default_visibility: builtIn.default_visibility, + activity_processors: builtIn.activity_processors, + aggregation: builtIn.aggregation, + notification: builtIn.notification, + push_notification: builtIn.push_notification, + ranking: builtIn.ranking, + }); + return { id, builtIn }; +}; + +/** The types of every notification activity in a user's built-in `notification` feed (sorted). */ +export const notificationTypes = async ( + client: StreamClient, + userId: string, +) => { + const response = await client.feeds + .feed('notification', userId) + .getOrCreate({ user_id: userId }); + return response.aggregated_activities + .flatMap((group) => group.activities.map((a) => a.type)) + .sort(); +}; + +/** Retries until the user's notification types equal `expected` (sorted); returns them. */ +export const waitForNotificationTypes = ( + client: StreamClient, + userId: string, + expected: string[], +) => + retry(async () => { + const types = await notificationTypes(client, userId); + expect(types).toEqual([...expected].sort()); + return types; + }); + +/** + * Deletes a membership level, retrying while feed members still use it (code 17 + * "Cannot delete membership level that is currently in use by feed members"; feed + * deletes take a moment). "Not found" counts as deleted. + */ +export const deleteMembershipLevel = async ( + client: StreamClient, + id: string, +) => { + try { + await retry(() => client.feeds.deleteMembershipLevel({ id }), { + interval: 2000, + retryIf: (error) => String(error).includes('in use'), + }); + } catch (error) { + if (!/not found|does not exist|can't be found/i.test(String(error))) { + throw error; + } + } +}; diff --git a/__tests__/docs-snippets/helpers/leaks.ts b/__tests__/docs-snippets/helpers/leaks.ts index c20f42a..977fb4d 100644 --- a/__tests__/docs-snippets/helpers/leaks.ts +++ b/__tests__/docs-snippets/helpers/leaks.ts @@ -1,5 +1,10 @@ import type { StreamClient } from '../../../index'; import { DOCS_TEST_PREFIX } from './ids'; +import { deleteMembershipLevel } from './feeds'; +import { + deleteExternalStorageIfExists, + deleteFeedModerationTemplate, +} from './server'; import { hardDeleteUsers } from './users'; import { retry, retryOnRateLimit, waitForTask } from './wait'; @@ -19,14 +24,14 @@ export type Leftover = { /** * Decides which resources are leftovers. `matches` gets ids / names (and a poll's - * `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`. + * `created_by_id`). Channels, calls and feeds can't be searched by id prefix: they are + * found by `ids`, and with `createdSince` also by listing every one created since then + * and keeping the ones whose id `matches`. */ export type LeftoverFilter = { matches: (idOrName: string) => boolean; - channelIds?: string[]; - channelsCreatedSince?: Date; + ids?: string[]; + createdSince?: Date; }; /** Channel queries can't page past this offset. */ @@ -48,6 +53,13 @@ const hardDeleteChannels = async (client: StreamClient, cids: string[]) => { */ const POLL_QUERY_USER_ID = 'docs-snippets-leak-checker'; +/** + * Moderation custom views are only listed for an admin `user_id`, and `listQueues` + * re-creates a deleted user, so a permanent admin helper user (outside the docs-test + * prefix) is used. + */ +const QUEUE_QUERY_USER_ID = 'docs-snippets-leak-checker-admin'; + type Scanner = ( client: StreamClient, filter: LeftoverFilter, @@ -98,7 +110,7 @@ const scanners: Record = { return found; }, - async channels(client, { matches, channelIds = [], channelsCreatedSince }) { + async channels(client, { matches, ids: channelIds = [], createdSince }) { const found = new Map(); const add = (channel: { cid: string; created_at: Date }) => found.set(channel.cid, { @@ -121,18 +133,18 @@ const scanners: Record = { }); for (const { channel } of channels) if (channel) add(channel); } - if (channelsCreatedSince) { + if (createdSince) { 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`, + `more than ${MAX_CHANNEL_OFFSET} channels created since ${createdSince.toISOString()}, the rest were not scanned`, ); } const { channels } = await retryOnRateLimit(() => client.chat.queryChannels({ filter_conditions: { - created_at: { $gte: channelsCreatedSince.toISOString() }, + created_at: { $gte: createdSince.toISOString() }, }, sort: [{ field: 'created_at', direction: 1 }], limit, @@ -360,9 +372,258 @@ const scanners: Record = { } while (next); return found; }, + + async externalStorages(client, { matches }) { + const { external_storages } = await client.listExternalStorage(); + return Object.keys(external_storages) + .filter((name) => matches(name)) + .map((name) => ({ + kind: 'external storage', + id: name, + remove: () => deleteExternalStorageIfExists(client, name), + })); + }, + + async callTypes(client, { matches }) { + const { call_types } = await client.video.listCallTypes(); + return Object.values(call_types) + .filter((type) => matches(type.name)) + .map((type) => ({ + kind: 'call type', + id: type.name, + createdAt: type.created_at, + remove: () => client.video.deleteCallType({ name: type.name }), + })); + }, + + async calls(client, { matches, ids = [], createdSince }) { + const found = new Map(); + // Paged by `created_at`: a `next` request must repeat the original `sort`, and + // without one it fails with code 4 "cannot specify sort and next/prev at the same + // time" (2026-10-16). + const scan = async (filter_conditions: Record) => { + let after: Date | undefined; + for (;;) { + const response = await retryOnRateLimit(() => + client.video.queryCalls({ + filter_conditions: after + ? { $and: [filter_conditions, { created_at: { $gt: after } }] } + : filter_conditions, + sort: [{ field: 'created_at', direction: 1 }], + limit: 100, + }), + ); + for (const { call } of response.calls) { + if (!matches(call.id)) continue; + found.set(call.cid, { + kind: 'call', + id: call.cid, + createdAt: call.created_at, + remove: () => + client.video.deleteCall({ + type: call.type, + id: call.id, + hard: true, + }), + }); + } + if (response.calls.length < 100) return; + after = response.calls[response.calls.length - 1].call.created_at; + } + }; + for (const batch of chunk(ids, 100)) await scan({ id: { $in: batch } }); + if (createdSince) { + await scan({ created_at: { $gte: createdSince.toISOString() } }); + } + return [...found.values()]; + }, + + async feedGroups(client, { matches }) { + const { groups } = await client.feeds.listFeedGroups({ + include_soft_deleted: true, + }); + return Object.values(groups) + .filter((group) => matches(group.id)) + .map((group) => ({ + kind: 'feed group', + id: group.id, + createdAt: group.created_at, + remove: () => + client.feeds.deleteFeedGroup({ id: group.id, hard_delete: true }), + })); + }, + + async feeds(client, { matches, ids = [], createdSince }) { + const found = new Map(); + const scan = async (filter: Record) => { + let next: string | undefined; + do { + const response = await retryOnRateLimit(() => + client.feeds.queryFeeds({ filter, limit: 100, next }), + ); + for (const feed of response.feeds) { + if (!matches(feed.id)) continue; + found.set(feed.feed, { + kind: 'feed', + id: feed.feed, + createdAt: feed.created_at, + remove: () => + client.feeds.deleteFeed({ + feed_group_id: feed.group_id, + feed_id: feed.id, + hard_delete: true, + }), + }); + } + next = response.next; + } while (next); + }; + for (const batch of chunk(ids, 100)) await scan({ id: { $in: batch } }); + if (createdSince) { + await scan({ created_at: { $gte: createdSince.toISOString() } }); + } + return [...found.values()]; + }, + + async membershipLevels(client, { matches }) { + const found: Leftover[] = []; + let next: string | undefined; + do { + const response = await client.feeds.queryMembershipLevels({ + limit: 100, + next, + }); + for (const level of response.membership_levels) { + if (!matches(level.id) && !matches(level.name)) continue; + found.push({ + kind: 'membership level', + id: level.id, + createdAt: level.created_at, + remove: () => deleteMembershipLevel(client, level.id), + }); + } + next = response.next; + } while (next); + return found; + }, + + async collections(client, { matches }) { + const found: Leftover[] = []; + let next: string | undefined; + do { + const response = await client.feeds.queryCollections({ + limit: 100, + next, + }); + for (const collection of response.collections) { + if (!matches(collection.name) && !matches(collection.id)) continue; + const ref = `${collection.name}:${collection.id}`; + found.push({ + kind: 'collection', + id: ref, + createdAt: collection.created_at, + remove: () => + client.feeds.deleteCollections({ collection_refs: [ref] }), + removeMany: async (refs) => { + for (const batch of chunk(refs, 100)) { + await client.feeds.deleteCollections({ collection_refs: batch }); + } + }, + }); + } + next = response.next; + } while (next); + return found; + }, + + async feedViews(client, { matches }) { + const { views } = await client.feeds.listFeedViews(); + return Object.values(views) + .filter((view) => matches(view.id)) + .map((view) => ({ + kind: 'feed view', + id: view.id, + remove: () => client.feeds.deleteFeedView({ id: view.id }), + })); + }, + + async feedModerationTemplates(client, { matches }) { + const { templates } = await client.moderation.v2QueryTemplates(); + return templates + .filter((template) => matches(template.name)) + .map((template) => ({ + kind: 'feed moderation template', + id: template.name, + createdAt: template.created_at, + remove: () => deleteFeedModerationTemplate(client, template.name), + })); + }, + + async policyTestSets(client, { matches }) { + const { sets } = await client.moderation.listPolicyTestSets(); + return sets + .filter((set) => matches(set.name)) + .map((set) => ({ + kind: 'moderation evaluation set', + id: `${set.id} (${set.name})`, + createdAt: set.created_at, + remove: () => client.moderation.deletePolicyTestSet({ id: set.id }), + })); + }, + + async moderationQueues(client, { matches }) { + await client.upsertUsers([ + { + id: QUEUE_QUERY_USER_ID, + name: 'Docs snippets leak checker (admin)', + role: 'admin', + }, + ]); + const { queues } = await client.moderation.listQueues({ + user_id: QUEUE_QUERY_USER_ID, + }); + return queues + .filter((queue) => matches(queue.name)) + .map((queue) => ({ + kind: 'moderation view', + id: `${queue.id} (${queue.name})`, + createdAt: queue.created_at, + remove: () => + client.moderation.deleteQueue({ + id: queue.id, + user_id: QUEUE_QUERY_USER_ID, + }), + })); + }, + + async moderationRules(client, { matches }) { + const found: Leftover[] = []; + let next: string | undefined; + do { + const response = await client.moderation.queryModerationRules({ + limit: 100, + next, + }); + for (const rule of response.rules) { + if (!matches(rule.id) && !matches(rule.name)) continue; + found.push({ + kind: 'moderation rule', + id: `${rule.id} (${rule.name})`, + createdAt: rule.created_at, + remove: () => client.moderation.deleteModerationRule({ id: rule.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. */ +/** + * Resource kinds that are checked. Anything else (reminders, devices, drafts, ...) is + * per-user and goes away with the user; activities, comments and reactions go away with + * their feed or user. + */ export const SCANNED_KINDS = Object.keys(scanners); /** @@ -399,7 +660,15 @@ export const removeLeftovers = async (leftovers: Leftover[]) => { 'segment', 'channel', 'channel type', + 'call', + 'call type', + 'external storage', + 'feed', + 'feed group', + 'membership level', + 'feed view', 'user', + 'moderation rule', 'moderation config', ]; const rank = (kind: string) => diff --git a/__tests__/docs-snippets/helpers/server.ts b/__tests__/docs-snippets/helpers/server.ts index ea32562..f3a3ed9 100644 --- a/__tests__/docs-snippets/helpers/server.ts +++ b/__tests__/docs-snippets/helpers/server.ts @@ -1,10 +1,13 @@ import { isDeepStrictEqual } from 'node:util'; import type { + EventHook, MessageRequest, QueryChannelsRequest, SendMessageRequest, StreamClient, } from '../../../index'; +import type { Cleanup } from './cleanup'; +import { retry } from './wait'; /** * Server-side lookups and actions the tests repeat. Lookups throw unless exactly one @@ -114,6 +117,59 @@ export const waitForAppSetting = async ( } }; +/** Hooks without their server-managed timestamps (they change on every update). */ +export const withoutTimestamps = (hooks: EventHook[]) => + hooks.map((hook) => { + const copy = { ...hook }; + delete copy.created_at; + delete copy.updated_at; + return copy; + }); + +/** Reads `event_hooks` until `check` accepts them (getApp is eventually consistent). */ +export const waitForEventHooks = ( + serverClient: StreamClient, + check: (hooks: EventHook[]) => boolean, +) => + retry(async () => { + const { app } = await serverClient.getApp(); + if (!check(app.event_hooks)) { + const targets = app.event_hooks.map( + (h) => h.webhook_url ?? h.sqs_queue_url ?? h.sns_topic_arn, + ); + throw new Error( + `event_hooks not updated yet: ${JSON.stringify(targets)}`, + ); + } + return app.event_hooks; + }); + +/** + * Saves the app's `event_hooks` and registers their restore on `cleanup`. `restore()` + * puts the saved hooks back (same ids, so no DRIFT) and waits until 5 reads in a row + * agree, ignoring `created_at` / `updated_at`; call it before a snippet that claims to + * keep the existing hooks. + */ +export const saveAndRestoreEventHooks = async ( + serverClient: StreamClient, + cleanup: Cleanup, +) => { + const original = (await serverClient.getApp()).app.event_hooks; + const restore = async () => { + await serverClient.updateApp({ event_hooks: original }); + for (let agreeing = 0; agreeing < 5; agreeing++) { + await waitForEventHooks(serverClient, (hooks) => + isDeepStrictEqual( + withoutTimestamps(hooks), + withoutTimestamps(original), + ), + ); + } + }; + cleanup.add(restore); + return { original, restore }; +}; + /** * `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` @@ -146,3 +202,57 @@ export const waitForTeamIdSupport = async ( await new Promise((resolve) => setTimeout(resolve, interval)); } }; + +/** + * The review queue item of `entityId` once it has `flags` flags (flags arrive within + * about a second of `client.moderation.flag`). Throws unless exactly one item matches. + */ +export const waitForReviewItem = ( + client: StreamClient, + entityId: string, + { flags = 1 }: { flags?: number } = {}, +) => + retry(async () => { + const { items } = await client.moderation.queryReviewQueue({ + filter: { entity_id: entityId }, + }); + const item = expectOne(items, `review queue item for ${entityId}`); + if (item.flags.length !== flags) { + throw new Error(`expected ${flags} flag(s), got ${item.flags.length}`); + } + return item; + }); + +/** + * Deletes a Feeds v2 moderation template. The generated `v2DeleteTemplate()` takes no + * arguments, but the API needs `{ name }` in the DELETE body. + */ +export const deleteFeedModerationTemplate = ( + client: StreamClient, + name: string, +) => + client.moderation.apiClient.sendRequest( + 'DELETE', + '/api/v2/moderation/feeds_moderation_template', + undefined, + undefined, + { name }, + ); + +/** + * Deletes an external storage if it exists. Right after a create, the delete can fail + * with code 4 "external storage ... does not exist" while the storage is still listed, + * so it is retried until the name is gone from `listExternalStorage`. + */ +export const deleteExternalStorageIfExists = ( + client: StreamClient, + name: string, +) => + retry( + async () => { + const { external_storages } = await client.listExternalStorage(); + if (!(name in external_storages)) return; + await client.deleteExternalStorage({ name }); + }, + { retryIf: (error) => String(error).includes('does not exist') }, + ); diff --git a/__tests__/docs-snippets/helpers/uploads.ts b/__tests__/docs-snippets/helpers/uploads.ts new file mode 100644 index 0000000..60f0600 --- /dev/null +++ b/__tests__/docs-snippets/helpers/uploads.ts @@ -0,0 +1,19 @@ +import { StreamError } from '../../../index'; + +/** A 4x4 JPEG, for image upload fixtures (write it to an `os.tmpdir()` dir or wrap it in a `File`). */ +export const JPEG_BASE64 = + '/9j/4AAQSkZJRgABAQAAkACQAAD/4QCARXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAACQAAAAAQAAAJAAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAASgAwAEAAAAAQAAAAQAAAAA/8AAEQgABAAEAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8A/fSJPL34JO5i3J6Z7D2qXNNHeloA/9k='; + +/** The bytes of `JPEG_BASE64`. */ +export const jpeg = Buffer.from(JPEG_BASE64, 'base64'); + +/** A minimal PDF, for file upload fixtures. */ +export const MINIMAL_PDF = '%PDF-1.4\n%%EOF\n'; + +/** Swallows the 404 of deleting an upload that is already gone. */ +export const ignoreMissingFile = (error: unknown) => { + if (error instanceof StreamError && error.metadata?.responseCode === 404) { + return; + } + throw error; +}; diff --git a/__tests__/docs-snippets/helpers/video.ts b/__tests__/docs-snippets/helpers/video.ts new file mode 100644 index 0000000..9b88c25 --- /dev/null +++ b/__tests__/docs-snippets/helpers/video.ts @@ -0,0 +1,46 @@ +import { createUserToken } from './clients'; + +const VIDEO_API = 'https://video.stream-io-api.com/api/v2/video'; + +/** + * Sends a POST to the video API as `userId` (user token), for client-side endpoints + * the server SDK can't call (join, feedback, ...). Throws on a non-2xx response. + */ +export const postAsVideoUser = async ( + userId: string, + path: string, + body: object, +) => { + const response = await fetch( + `${VIDEO_API}${path}?api_key=${process.env.STREAM_API_KEY}`, + { + method: 'POST', + headers: { + Authorization: createUserToken(userId), + 'stream-auth-type': 'jwt', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }, + ); + const json = (await response.json()) as T; + if (!response.ok) { + throw new Error(`${path}: ${response.status} ${JSON.stringify(json)}`); + } + return json; +}; + +/** + * Joins a call as `userId` over REST (no WebRTC). This starts a call session, so + * egress that needs one works afterwards: recording, transcription, closed captions, + * HLS, frame recording (start and stop, 2026-10-16). Returns the session id. + */ +export const joinCallAsUser = async ( + call: { type: string; id: string }, + userId: string, +) => { + const { call: joined } = await postAsVideoUser<{ + call: { current_session_id: string }; + }>(userId, `/call/${call.type}/${call.id}/join`, { location: 'AMS' }); + return joined.current_session_id; +}; diff --git a/__tests__/docs-snippets/maintenance/sweep.ts b/__tests__/docs-snippets/maintenance/sweep.ts index 0728416..7a2bc3d 100644 --- a/__tests__/docs-snippets/maintenance/sweep.ts +++ b/__tests__/docs-snippets/maintenance/sweep.ts @@ -34,15 +34,15 @@ 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: list every channel and keep the docs-test + // Channels, calls and feeds can't be searched by id prefix: list every one and keep the docs-test // ones (they can outlive their creator, e.g. when a file crashed before cleanup). - channelsCreatedSince: new Date(0), + createdSince: 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 + // another; the docs test app has none (see "Test app" in AGENTS.md), 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) { diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/01-basics/01-quickstart.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/01-basics/01-quickstart.test.ts new file mode 100644 index 0000000..abbb7e4 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/01-basics/01-quickstart.test.ts @@ -0,0 +1,289 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { + AddActivityResponse, + PollVoteResponse, +} 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 = 'activity-feeds/v3-latest/_default/01-basics/01-quickstart.md'; + +// Values the module-level region below uses (it must come after them: TDZ). +// The docs' placeholders are COPY-mapped to the test app's credentials. +const streamApiKey = process.env.STREAM_API_KEY as string; +const streamSecret = process.env.STREAM_SECRET as string; +// The docs' "john" user, test-owned. +const johnId = uniqueId('john'); +const johnUserFeed = `user:${johnId}`; +const serverClient = getServerClient(); +const cleanup = new Cleanup(serverClient); +cleanup.users.push(johnId); +cleanup.feeds.push(johnUserFeed); +cleanup.add(() => deletePollsCreatedBy(serverClient, johnId)); +await serverClient.upsertUsers([{ id: johnId }]); + +// #region snippet docs="activity-feeds/v3-latest/_default/01-basics/01-quickstart.md" heading="Getting started" tab="Node.js" index=1 +// COPY: streamApiKey="", streamSecret="", johnId="john", johnUserFeed="user:john" +import { StreamClient } from '@stream-io/node-sdk'; + +// Initialize the client +const client = new StreamClient(streamApiKey, streamSecret); + +// Create a feed (or get its data if exists) +const feed = client.feeds.feed('user', johnId); +await feed.getOrCreate({ user_id: johnId }); + +// Add activity +await client.feeds.addActivity({ + type: 'post', + feeds: [johnUserFeed], + text: 'Hello, Stream Feeds!', + user_id: johnId, +}); +// #endregion snippet + +describe(DOCS, () => { + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates the user feed and adds an activity', async () => { + expect(feed.id).toBe(johnId); + const response = await serverClient.feeds.feed('user', johnId).getOrCreate({ + user_id: johnId, + }); + expect(response.activities.map((a) => [a.type, a.text])).toEqual([ + ['post', 'Hello, Stream Feeds!'], + ]); + expect(response.activities[0].user.id).toBe(johnId); + }); + + it('adds reactions and comments', async () => { + cleanup.feeds.push(`timeline:${johnId}`); + // The docs' "activity_123" and "comment_456" placeholders, created here. + const { activity } = await serverClient.feeds.addActivity({ + type: 'post', + feeds: [johnUserFeed], + text: 'Reaction target', + user_id: johnId, + }); + const activityId = activity.id; + const { comment: existingComment } = await serverClient.feeds.addComment({ + object_id: activityId, + object_type: 'activity', + comment: 'An existing comment', + user_id: johnId, + }); + const commentId = existingComment.id; + + // #region snippet docs="activity-feeds/v3-latest/_default/01-basics/01-quickstart.md" heading="Social media feed" tab="Node.js" index=1 + // COPY: johnId="john", activityId="activity_123", commentId="comment_456" + // Create a timeline feed + const timeline = client.feeds.feed('timeline', johnId); + await timeline.getOrCreate({ user_id: johnId }); + + // Add a reaction to activity + await client.feeds.addActivityReaction({ + activity_id: activityId, + type: 'like', + user_id: johnId, + }); + + // Add a comment to activity + await client.feeds.addComment({ + object_id: activityId, + object_type: 'activity', + comment: 'Great post!', + user_id: johnId, + }); + + // Add a reaction to comment + await client.feeds.addCommentReaction({ + id: commentId, + type: 'love', + user_id: johnId, + }); + // #endregion snippet + + expect(timeline.id).toBe(johnId); + const { feed: timelineFeed } = await serverClient.feeds + .feed('timeline', johnId) + .getOrCreate({ user_id: johnId }); + expect(timelineFeed.feed).toBe(`timeline:${johnId}`); + + const { activity: updated } = await serverClient.feeds.getActivity({ + id: activityId, + }); + expect(updated.reaction_groups.like?.count).toBe(1); + expect(updated.latest_reactions.map((r) => [r.type, r.user.id])).toEqual([ + ['like', johnId], + ]); + expect(updated.comment_count).toBe(2); + + const { comments } = await serverClient.feeds.getComments({ + object_id: activityId, + object_type: 'activity', + }); + expect(comments.map((c) => c.text).sort()).toEqual([ + 'An existing comment', + 'Great post!', + ]); + + const { comment } = await serverClient.feeds.getComment({ id: commentId }); + expect(comment.reaction_groups?.love?.count).toBe(1); + }); + + it('marks notifications as read', async () => { + const notificationFeed = `notification:${johnId}`; + cleanup.feeds.push(notificationFeed); + // An unread notification for john: another user's activity in his notification feed. + const otherId = uniqueId('notifier'); + cleanup.users.push(otherId); + await serverClient.upsertUsers([{ id: otherId }]); + await serverClient.feeds + .feed('notification', johnId) + .getOrCreate({ user_id: johnId }); + await serverClient.feeds.addActivity({ + type: 'like', + feeds: [notificationFeed], + text: 'liked your post', + user_id: otherId, + }); + const before = await retry(async () => { + const response = await serverClient.feeds + .feed('notification', johnId) + .getOrCreate({ user_id: johnId }); + if (!response.notification_status?.unread) { + throw new Error('no unread notification yet'); + } + return response; + }); + expect(before.notification_status?.unread).toBeGreaterThan(0); + + // #region snippet docs="activity-feeds/v3-latest/_default/01-basics/01-quickstart.md" heading="Notification feed" tab="Node.js" index=1 + // COPY: johnId="john" + // Create a notification feed + const notifications = client.feeds.feed('notification', johnId); + await notifications.getOrCreate({ user_id: johnId }); + + // Mark notifications as read + await notifications.markActivity({ + mark_all_read: true, + user_id: johnId, + }); + // #endregion snippet + + const after = await retry(async () => { + const response = await serverClient.feeds + .feed('notification', johnId) + .getOrCreate({ user_id: johnId }); + if (response.notification_status?.unread !== 0) { + throw new Error('notifications still unread'); + } + return response; + }); + expect(after.notification_status?.unread).toBe(0); + expect(after.notification_status?.last_read_at).toBeInstanceOf(Date); + }); + + it('creates a poll, attaches it to an activity and votes', async () => { + // The docs' "existing poll" ("poll_456", option "option_789", activity "activity_123"). + const existing = await serverClient.createPoll({ + name: 'An existing poll', + options: [{ text: 'Yes' }, { text: 'No' }], + user_id: johnId, + }); + const pollId = existing.poll.id; + const optionId = existing.poll.options[0].id; + const { activity: pollActivity } = await serverClient.feeds.addActivity({ + feeds: [johnUserFeed], + text: 'An existing poll', + type: 'poll', + poll_id: pollId, + user_id: johnId, + }); + const activityId = pollActivity.id; + const castPollVote = vi.spyOn(client.feeds, 'castPollVote'); + + // #region snippet docs="activity-feeds/v3-latest/_default/01-basics/01-quickstart.md" heading="Polls" tab="Node.js" index=1 + // COPY: johnId="john", johnUserFeed="user:john", activityId="activity_123", pollId="poll_456", optionId="option_789" + const poll = await client.createPoll({ + name: 'What is your favorite color?', + options: [{ text: 'Red' }, { text: 'Blue' }, { text: 'Green' }], + user_id: johnId, + }); + + // Attach it to an activity + const activity = await client.feeds.addActivity({ + feeds: [johnUserFeed], + text: 'What is your favorite color?', + type: 'poll', + poll_id: poll.poll.id, + user_id: johnId, + }); + + // Cast a vote on an existing poll + await client.feeds.castPollVote({ + activity_id: activityId, + poll_id: pollId, + user_id: johnId, + vote: { + option_id: optionId, + }, + }); + // #endregion snippet + + expect(poll.poll.options.map((o) => o.text)).toEqual([ + 'Red', + 'Blue', + 'Green', + ]); + expect(activity.activity.poll?.id).toBe(poll.poll.id); + expect(activity.activity.type).toBe('poll'); + + const { vote } = await (castPollVote.mock.results[0] + .value as Promise); + expect(vote?.option_id).toBe(optionId); + expect(vote?.user_id).toBe(johnId); + const { activity: voted } = await serverClient.feeds.getActivity({ + id: activityId, + }); + expect(voted.poll?.vote_count).toBe(1); + expect(voted.poll?.vote_counts_by_option[optionId]).toBe(1); + }); + + it('adds a custom activity with custom fields', async () => { + const add = vi.spyOn(client.feeds, 'addActivity'); + + // #region snippet docs="activity-feeds/v3-latest/_default/01-basics/01-quickstart.md" heading="Custom activity types" tab="Node.js" index=1 + // COPY: johnId="john", johnUserFeed="user:john" + // Add custom activity with custom fields + await client.feeds.addActivity({ + type: 'workout', + feeds: [johnUserFeed], + text: 'Just finished my run', + user_id: johnId, + custom: { + distance: 5.2, + duration: 1800, + calories: 450, + }, + }); + // #endregion snippet + + const { activity } = await (add.mock.results[0] + .value as Promise); + const { activity: stored } = await serverClient.feeds.getActivity({ + id: activity.id, + }); + expect(stored.type).toBe('workout'); + expect(stored.feeds).toEqual([johnUserFeed]); + expect(stored.custom).toEqual({ + distance: 5.2, + duration: 1800, + calories: 450, + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/01-feed-groups.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/01-feed-groups.test.ts new file mode 100644 index 0000000..24f0d59 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/01-feed-groups.test.ts @@ -0,0 +1,445 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + CreateFeedGroupResponse, + ListFeedGroupsResponse, + UpdateFeedGroupResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + isFeedGroupMissing, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +// Feed groups are app-wide: every group a fence creates gets a uniqueId id. +const newFeedGroupId = (name: string) => { + const id = uniqueId(name); + cleanup.feedGroups.push(id); + return id; +}; + +const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; +}; + +describe(DOCS, () => { + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates a feed group', async () => { + const myFeedGroupId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Create feed groups" tab="Node.js" index=1 + // COPY: myFeedGroupId="myid" + const myFeedGroup = await serverClient.feeds.createFeedGroup({ + id: myFeedGroupId, + activity_selectors: [ + { + type: 'following', + }, + ], + activity_processors: [ + { + type: 'text_interest_tags', + }, + ], + activity_filter: { + exclude_owner_activities: true, + }, + custom: { + description: 'My custom feed group', + }, + }); + // #endregion snippet + + const group = myFeedGroup.feed_group; + expect(group.id).toBe(myFeedGroupId); + expect(group.activity_selectors?.map((s) => s.type)).toEqual(['following']); + // Without `ranking` the group ranks by recency (the API rejects + // `type: "recency"`). + expect(group.ranking).toBeUndefined(); + expect(group.activity_processors?.map((p) => p.type)).toEqual([ + 'text_interest_tags', + ]); + expect(group.activity_filter?.exclude_owner_activities).toBe(true); + expect(group.custom).toEqual({ description: 'My custom feed group' }); + }); + + describe('activity marks on content feeds', () => { + const rankedTimelineId = newFeedGroupId('ranked-timeline'); + let userId: string; + + beforeAll(async () => { + userId = await createUser('reader'); + }); + + it('creates a feed group with activity marks', async () => { + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Activity marks on content feeds" tab="Node.js" index=1 + // COPY: rankedTimelineId="ranked_timeline" + await serverClient.feeds.createFeedGroup({ + id: rankedTimelineId, + activity_marks: { + track_seen: true, + track_read: true, + }, + activity_selectors: [{ type: 'following' }], + }); + // #endregion snippet + + const { feed_group } = await (create.mock.results[0] + .value as Promise); + expect(feed_group.id).toBe(rankedTimelineId); + expect(feed_group.activity_marks).toMatchObject({ + track_seen: true, + track_read: true, + }); + }); + + it('marks an activity as seen', async () => { + retryOnFeedGroupMissing(client, ['getOrCreateFeed', 'markActivity']); + // The docs' `timeline` (a feed of the group above) and `activity` in it: + // the timeline follows an author's user feed, the author posts. + const authorId = await createUser('author'); + cleanup.feeds.push(`user:${authorId}`, `${rankedTimelineId}:${userId}`); + const timeline = client.feeds.feed(rankedTimelineId, userId); + await timeline.getOrCreate({ user_id: userId }); + await client.feeds + .feed('user', authorId) + .getOrCreate({ user_id: authorId }); + await retry( + () => + client.feeds.follow({ + source: timeline.feed, + target: `user:${authorId}`, + }), + { retryIf: isFeedGroupMissing }, + ); + const { activity } = await client.feeds.addActivity({ + type: 'post', + feeds: [`user:${authorId}`], + text: 'Seen me?', + user_id: authorId, + }); + const before = await retry(async () => { + const response = await timeline.getOrCreate({ user_id: userId }); + const found = response.activities.find((a) => a.id === activity.id); + if (!found) throw new Error('activity not in the timeline yet'); + return found; + }); + expect(before.is_seen).toBe(false); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Activity marks on content feeds" tab="Node.js" index=2 + await timeline.markActivity({ + mark_seen: [activity.id], + user_id: userId, + }); + // #endregion snippet + + const after = await retry(async () => { + const response = await timeline.getOrCreate({ user_id: userId }); + const found = response.activities.find((a) => a.id === activity.id); + if (!found?.is_seen) throw new Error('activity not seen yet'); + return found; + }); + expect(after.is_seen).toBe(true); + expect(after.is_read).toBe(false); + }); + }); + + it('creates a feed group with a default follower role', async () => { + const premiumId = newFeedGroupId('premium'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Default follower role" tab="Node.js" index=1 + // COPY: premiumId="premium" + await client.feeds.createFeedGroup({ + id: premiumId, + default_follower_role: 'feed_member_viewer', + }); + // #endregion snippet + + const { feed_group } = await (create.mock.results[0] + .value as Promise); + expect(feed_group.id).toBe(premiumId); + expect(feed_group.default_follower_role).toBe('feed_member_viewer'); + }); + + it('lists feed groups', async () => { + const list = vi.spyOn(client.feeds, 'listFeedGroups'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="List feed groups" tab="Node.js" index=1 + await client.feeds.listFeedGroups(); + // #endregion snippet + + const { groups } = await (list.mock.results[0] + .value as Promise); + expect(Object.keys(groups)).toEqual( + expect.arrayContaining(['user', 'timeline', 'notification']), + ); + expect(groups.user.id).toBe('user'); + }); + + it('creates a feed group with expression ranking', async () => { + const myTimelineId = newFeedGroupId('mytimeline'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Activity Ranking" tab="Node.js" index=1 + // COPY: myTimelineId="mytimeline" + const response = await serverClient.feeds.createFeedGroup({ + id: myTimelineId, + ranking: { type: 'expression', score: 'decay_linear(time) * popularity' }, + activity_selectors: [ + { + type: 'following', + }, + ], + }); + // #endregion snippet + + expect(response.feed_group.id).toBe(myTimelineId); + expect(response.feed_group.ranking).toMatchObject({ + type: 'expression', + score: 'decay_linear(time) * popularity', + }); + }); + + it('creates a for you feed group and a feed in it', async () => { + const myTimelineId = newFeedGroupId('foryou'); + const thierryId = await createUser('thierry'); + cleanup.feeds.push(`${myTimelineId}:${thierryId}`); + retryOnFeedGroupMissing(client, ['getOrCreateFeed', 'markActivity']); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="For you Feeds" tab="Node.js" index=1 + // COPY: myTimelineId="mytimeline", thierryId="thierry" + const response = await serverClient.feeds.createFeedGroup({ + id: myTimelineId, + // Run the activity processors to analyse topics for text & images + activity_processors: [ + { type: 'text_interest_tags' }, + { type: 'image_interest_tags' }, + ], + // Activity selectors change which activities are included in the feed + // The default "following" selectors gets activities from the feeds you follow + // The "popular" activity selectors includes the popular activities + // And "interest" activities similar to activities you've engaged with in the past + // You can use multiple selectors in 1 feed + activity_selectors: [ + { + type: 'popular', + }, + { + type: 'following', + }, + { + type: 'interest', + }, + ], + // Rank for a user based on interest score + // This calculates a score 0-1.0 of how well the activity matches the user's prior interest + ranking: { + type: 'interest', + score: 'decay_linear(time) * interest_score * decay_linear(popularity)', + }, + }); + + const forYouFeed = client.feeds.feed(myTimelineId, thierryId); + const feedResponse = await forYouFeed.getOrCreate({ user_id: thierryId }); + // #endregion snippet + + expect(response.feed_group.activity_selectors?.map((s) => s.type)).toEqual([ + 'popular', + 'following', + 'interest', + ]); + expect(response.feed_group.activity_processors?.map((p) => p.type)).toEqual( + ['text_interest_tags', 'image_interest_tags'], + ); + expect(response.feed_group.ranking?.type).toBe('interest'); + expect(feedResponse.feed.feed).toBe(`${myTimelineId}:${thierryId}`); + }); + + it('creates an aggregated notification feed group', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Aggregation Format" tab="Node.js" index=1 + // COPY: myId="myid" + const myNotificationGrpup = await serverClient.feeds.createFeedGroup({ + id: myId, + // Group by activity type and day + aggregation: { format: '{{ type }}-{{ time.strftime("%Y-%m-%d") }}' }, + // Enable notification tracking + notification: { + track_read: true, + track_seen: true, + }, + }); + // #endregion snippet + + const group = myNotificationGrpup.feed_group; + expect(group.id).toBe(myId); + expect(group.aggregation?.format).toBe( + '{{ type }}-{{ time.strftime("%Y-%m-%d") }}', + ); + expect(group.notification).toMatchObject({ + track_read: true, + track_seen: true, + }); + }); + + describe('notification feeds', () => { + let janeId: string; + + beforeAll(async () => { + // The docs' "jane": a notification feed with an unread notification + // from another user. + janeId = await createUser('jane'); + const otherId = await createUser('notifier'); + cleanup.feeds.push(`notification:${janeId}`); + await client.feeds + .feed('notification', janeId) + .getOrCreate({ user_id: janeId }); + await client.feeds.addActivity({ + type: 'like', + feeds: [`notification:${janeId}`], + text: 'liked your post', + user_id: otherId, + }); + await retry(async () => { + const response = await client.feeds + .feed('notification', janeId) + .getOrCreate({ user_id: janeId }); + if (!response.notification_status?.unread) { + throw new Error('no unread notification yet'); + } + }); + }); + + it('reads a notification feed', async () => { + const userId = janeId; + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Notification Feed Example" tab="Node.js" index=1 + // COPY: janeId="jane", userId="" + const notificationFeed = client.feeds.feed('notification', janeId); + + // Read notifications + const notifications = ( + await notificationFeed.getOrCreate({ + limit: 20, + user_id: userId, + }) + ).aggregated_activities; + // #endregion snippet + + expect(notifications).toHaveLength(1); + expect(notifications[0].activities.map((a) => a.text)).toEqual([ + 'liked your post', + ]); + }); + + it('marks notifications as read', async () => { + const userId = janeId; + const notificationFeed = client.feeds.feed('notification', janeId); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Marking Notifications as Read" tab="Node.js" index=1 + // COPY: userId="" + await notificationFeed.markActivity({ + // Mark all notifications as read... + mark_all_read: true, + // ...or only selected ones + mark_read: [/* group names to mark as read */], + user_id: userId, + }); + // #endregion snippet + + const after = await retry(async () => { + const response = await notificationFeed.getOrCreate({ + user_id: janeId, + }); + if (response.notification_status?.unread !== 0) { + throw new Error('notifications still unread'); + } + return response; + }); + expect(after.notification_status?.unread).toBe(0); + }); + }); + + it('updates a feed group', async () => { + const feedGroupId = newFeedGroupId('to-update'); + await client.feeds.createFeedGroup({ + id: feedGroupId, + custom: { description: 'Before the update' }, + }); + const updateFeedGroup = client.feeds.updateFeedGroup.bind(client.feeds); + const update = vi.spyOn(client.feeds, 'updateFeedGroup'); + update.mockImplementation((request) => + retry(() => updateFeedGroup(request), { retryIf: isFeedGroupMissing }), + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Updating feed groups" tab="Node.js" index=1 + // COPY: feedGroupId="" + await client.feeds.updateFeedGroup({ + id: feedGroupId, + // Fields to update + }); + // #endregion snippet + + const { feed_group } = await (update.mock.results[0] + .value as Promise); + expect(feed_group.id).toBe(feedGroupId); + // "Updates replace every field": the omitted custom data is cleared. + expect(feed_group.custom ?? {}).toEqual({}); + }); + + it('deletes a feed group', async () => { + const feedGroupId = newFeedGroupId('to-delete'); + await client.feeds.createFeedGroup({ id: feedGroupId }); + const deleteFeedGroup = client.feeds.deleteFeedGroup.bind(client.feeds); + const del = vi.spyOn(client.feeds, 'deleteFeedGroup'); + del.mockImplementation((request) => + retry(() => deleteFeedGroup(request), { retryIf: isFeedGroupMissing }), + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/01-feed-groups.md" heading="Deleting feed groups" tab="Node.js" index=1 + // COPY: feedGroupId="" + await serverClient.feeds.deleteFeedGroup({ + id: feedGroupId, + }); + // #endregion snippet + + vi.restoreAllMocks(); + // A soft delete: the group is gone from the list, but still there with + // include_soft_deleted. + const deleted = await retry(async () => { + const { groups } = await client.feeds.listFeedGroups(); + if (groups[feedGroupId]) throw new Error('group still listed'); + const all = await client.feeds.listFeedGroups({ + include_soft_deleted: true, + }); + return all.groups[feedGroupId]; + }); + expect(deleted?.deleted_at).toBeInstanceOf(Date); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/02-feed-views.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/02-feed-views.test.ts new file mode 100644 index 0000000..3eda005 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/02-feed-views.test.ts @@ -0,0 +1,185 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { + CreateFeedViewResponse, + GetOrCreateFeedResponse, + ListFeedViewsResponse, + UpdateFeedViewResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + isFeedGroupMissing, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/03-feed_groups/02-feed-views.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +// Feed views are app-wide: every view gets a uniqueId id and is deleted in +// cleanup (a snippet may delete it first). +const newFeedViewId = (name: string) => { + const id = uniqueId(name); + cleanup.add(() => + client.feeds.deleteFeedView({ id }).catch((error: unknown) => { + if (!String(error).includes('not found')) throw error; + }), + ); + return id; +}; + +// A new feed view isn't known to every API node right away ("feed view not +// found" / "Feed view with ID ... not found"). +const createFeedView = async (name: string) => { + const id = newFeedViewId(name); + await client.feeds.createFeedView({ + id, + activity_selectors: [{ type: 'following' }], + }); + await retry(() => client.feeds.getFeedView({ id }), { + retryIf: isFeedGroupMissing, + }); + return id; +}; + +const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; +}; + +describe(DOCS, () => { + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates a feed view', async () => { + const viewId = newFeedViewId('view'); + const create = vi.spyOn(client.feeds, 'createFeedView'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/02-feed-views.md" heading="When to use feed views" tab="Node.js" index=1 + // COPY: viewId="" + await serverClient.feeds.createFeedView({ + id: viewId, + activity_selectors: [ + { + type: 'following', + }, + ], + }); + // #endregion snippet + + const { feed_view } = await (create.mock.results[0] + .value as Promise); + expect(feed_view.id).toBe(viewId); + expect(feed_view.activity_selectors?.map((s) => s.type)).toEqual([ + 'following', + ]); + // Without `ranking` the view ranks by recency (the API rejects + // `type: "recency"`). + expect(feed_view.ranking).toBeUndefined(); + }); + + it('reads a feed with a feed view', async () => { + const viewId = await createFeedView('view'); + const userId = await createUser('reader'); + cleanup.feeds.push(`user:${userId}`); + const feed = client.feeds.feed('user', userId); + // Other API nodes may not know the view yet. + retryOnFeedGroupMissing(client, ['getOrCreateFeed']); + // The same spy: vi.spyOn returns the existing mock. + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/02-feed-views.md" heading="When to use feed views" tab="Node.js" index=2 + // COPY: viewId="", userId="" + await feed.getOrCreate({ + // Override the default view id + view: viewId, + user_id: userId, + }); + // #endregion snippet + + expect(getOrCreate.mock.calls[0][0]).toMatchObject({ view: viewId }); + const response = await (getOrCreate.mock.results[0] + .value as Promise); + expect(response.feed.feed).toBe(`user:${userId}`); + vi.restoreAllMocks(); + // An unknown view id is rejected, so the request above used the view. + await expect( + feed.getOrCreate({ view: uniqueId('missing-view'), user_id: userId }), + ).rejects.toMatchObject({ code: 16 }); + }); + + it('lists feed views', async () => { + const viewId = await createFeedView('listed'); + // The list is eventually consistent too: wait until it has the new view. + await retry(async () => { + const { views } = await client.feeds.listFeedViews(); + if (!views[viewId]) throw new Error('view not listed yet'); + }); + const list = vi.spyOn(client.feeds, 'listFeedViews'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/02-feed-views.md" heading="List feed views" tab="Node.js" index=1 + await client.feeds.listFeedViews(); + // #endregion snippet + + const { views } = await (list.mock.results[0] + .value as Promise); + expect(views[viewId]?.activity_selectors?.map((s) => s.type)).toEqual([ + 'following', + ]); + }); + + it('updates a feed view', async () => { + const viewId = await createFeedView('to-update'); + // A new view isn't known to every API node right away (code 16 "... not found"). + const updateFeedView = client.feeds.updateFeedView.bind(client.feeds); + const update = vi.spyOn(client.feeds, 'updateFeedView'); + update.mockImplementation((request) => + retry(() => updateFeedView(request), { retryIf: isFeedGroupMissing }), + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/02-feed-views.md" heading="Updating feed views" tab="Node.js" index=1 + // COPY: viewId="" + await serverClient.feeds.updateFeedView({ + id: viewId, + // Fields to update + }); + // #endregion snippet + + const { feed_view } = await (update.mock.results[0] + .value as Promise); + expect(feed_view.id).toBe(viewId); + // Updates replace every field: the omitted activity selectors are gone. + expect(feed_view.activity_selectors ?? []).toEqual([]); + }); + + it('deletes a feed view', async () => { + const viewId = await createFeedView('to-delete'); + const deleteFeedView = client.feeds.deleteFeedView.bind(client.feeds); + const del = vi.spyOn(client.feeds, 'deleteFeedView'); + del.mockImplementation((request) => + retry(() => deleteFeedView(request), { retryIf: isFeedGroupMissing }), + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/02-feed-views.md" heading="Deleting feed views" tab="Node.js" index=1 + // COPY: viewId="view id to delete" + await serverClient.feeds.deleteFeedView({ + id: viewId, + }); + // #endregion snippet + + expect(del).toHaveBeenCalledTimes(1); + vi.restoreAllMocks(); + // Deleting it again proves it is gone. + await expect( + client.feeds.deleteFeedView({ id: viewId }), + ).rejects.toMatchObject({ code: 16, metadata: { responseCode: 404 } }); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/03-aggregation.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/03-aggregation.test.ts new file mode 100644 index 0000000..56df1f1 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/03-aggregation.test.ts @@ -0,0 +1,184 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { + GetOrCreateFeedResponse, + UpdateFeedGroupResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { isFeedGroupMissing } from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { waitForAppSetting } from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/03-feed_groups/03-aggregation.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +// Feed groups are app-wide: every group a fence creates gets a uniqueId id. +const newFeedGroupId = (name: string) => { + const id = uniqueId(name); + cleanup.feedGroups.push(id); + return id; +}; + +const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; +}; + +describe(DOCS, () => { + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates an aggregated feed group', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/03-aggregation.md" heading="Creating aggregated feed groups" tab="Node.js" index=1 + // COPY: myId="myid" + const myNotificationGrpup = await serverClient.feeds.createFeedGroup({ + id: myId, + // Group by activity type and day + aggregation: { format: '{{ type }}-{{ time.strftime("%Y-%m-%d") }}' }, + // Enable notification tracking + notification: { + track_read: true, + track_seen: true, + }, + }); + // #endregion snippet + + const group = myNotificationGrpup.feed_group; + expect(group.id).toBe(myId); + expect(group.aggregation?.format).toBe( + '{{ type }}-{{ time.strftime("%Y-%m-%d") }}', + ); + expect(group.notification).toMatchObject({ + track_read: true, + track_seen: true, + }); + }); + + it('updates a feed group', async () => { + // The heading says "built-in", but built-in groups are app-wide: the + // fence runs on a uniqueId group. + const feedGroupId = newFeedGroupId('to-update'); + await client.feeds.createFeedGroup({ + id: feedGroupId, + custom: { description: 'Before the update' }, + }); + const updateFeedGroup = client.feeds.updateFeedGroup.bind(client.feeds); + const update = vi.spyOn(client.feeds, 'updateFeedGroup'); + update.mockImplementation((request) => + retry(() => updateFeedGroup(request), { retryIf: isFeedGroupMissing }), + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/03-aggregation.md" heading="Updating built-in feed groups" tab="Node.js" index=1 + // COPY: feedGroupId="" + await client.feeds.updateFeedGroup({ + id: feedGroupId, + // Fields to update + }); + // #endregion snippet + + const { feed_group } = await (update.mock.results[0] + .value as Promise); + expect(feed_group.id).toBe(feedGroupId); + // Updates replace every field: the omitted custom data is cleared. + expect(feed_group.custom ?? {}).toEqual({}); + }); + + it('reads flat activities of an aggregated feed', async () => { + // An aggregated feed: the built-in notification group, for a new user, + // with one notification from another user. + const userId = await createUser('reader'); + const otherId = await createUser('notifier'); + cleanup.feeds.push(`notification:${userId}`); + const feed = client.feeds.feed('notification', userId); + await feed.getOrCreate({ user_id: userId }); + const { activity } = await client.feeds.addActivity({ + type: 'like', + feeds: [`notification:${userId}`], + text: 'liked your post', + user_id: otherId, + }); + // Without flat activities only the groups come back. + const groupsOnly = await retry(async () => { + const response = await feed.getOrCreate({ + enrichment_options: { include_flat_activities: false }, + user_id: userId, + }); + if (!response.aggregated_activities.length) { + throw new Error('notification not aggregated yet'); + } + return response; + }); + expect(groupsOnly.activities).toEqual([]); + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/03-aggregation.md" heading="Flat activities in aggregated feeds" tab="Node.js" index=1 + await feed.getOrCreate({ + enrichment_options: { + include_flat_activities: true, // or false + }, + user_id: userId, + }); + // #endregion snippet + + const response = await (getOrCreate.mock.results[0] + .value as Promise); + expect(response.activities.map((a) => a.id)).toEqual([activity.id]); + expect( + response.aggregated_activities[0].activities.map((a) => a.id), + ).toEqual([activity.id]); + }); + + it('reads and updates the aggregation group limit', async () => { + const original = (await client.getApp()).app + .max_aggregated_activities_length; + cleanup.add(async () => { + await client.updateApp({ + max_aggregated_activities_length: original, + }); + await waitForAppSetting( + client, + 'max_aggregated_activities_length', + original, + ); + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const update = vi.spyOn(client, 'updateApp'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/03-aggregation.md" heading="Aggregation group limit" tab="Node.js" index=1 + const currentSettings = await client.getApp(); + + console.log(currentSettings.app.max_aggregated_activities_length); + + await client.updateApp({ + max_aggregated_activities_length: 20, + }); + // #endregion snippet + + expect(log.mock.calls[0]).toEqual([original]); + const response = await (update.mock.results[0].value as ReturnType< + typeof client.updateApp + >); + expect(response.metadata.responseCode).toBe(200); + vi.restoreAllMocks(); + // The limit is validated ("the maximum is 100")... + await expect( + client.updateApp({ max_aggregated_activities_length: 101 }), + ).rejects.toMatchObject({ code: 4 }); + // ...but (2026-10-16) the accepted value never reads back: getApp keeps + // returning the original (0), and groups of an aggregated feed still hold + // more than 20 activities. + expect((await client.getApp()).app.max_aggregated_activities_length).toBe( + original, + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/04-custom_ranking.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/04-custom_ranking.test.ts new file mode 100644 index 0000000..1aa27b6 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/04-custom_ranking.test.ts @@ -0,0 +1,932 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + ActivityResponse, + CreateFeedGroupResponse, + GetOrCreateFeedRequest, + GetOrCreateFeedResponse, + StreamFeed, + UpdateFeedGroupResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + addActivityOnce, + isFeedGroupMissing, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +// Feed groups are app-wide: every group a fence creates gets a uniqueId id. +const newFeedGroupId = (name: string) => { + const id = uniqueId(name); + cleanup.feedGroups.push(id); + return id; +}; + +const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; +}; + +/* + * Shared data: a reader whose feeds (one per ranked group) follow an author's + * user feed with three activities, posted oldest first (a0, a1, a2), so + * recency alone would order them a2, a1, a0: + * + * | activity | popularity | custom | interest_tags | + * | -------- | -------------------- | ------------------------------------------- | ------------- | + * | a0 | 5 (5 reactions) | stock: tesla, stocks: [] | exercise | + * | a1 | 4 (4 reactions) | isBoosted, stock: nokia, stocks: [microsoft] | food | + * | a2 | 3 (1 share, 3 points) | stock: apple, stocks: [apple, tesla] | travel | + */ +let authorId: string; +let readerId: string; +const names = new Map(); + +/** Our activities in feed order, by name. */ +const order = (response: GetOrCreateFeedResponse) => + ours(response).map((a) => names.get(a.id)); +const ours = (response: GetOrCreateFeedResponse) => + response.activities.filter((a) => names.has(a.id)); +const byName = (response: GetOrCreateFeedResponse) => + Object.fromEntries(ours(response).map((a) => [names.get(a.id), a])) as Record< + string, + ActivityResponse + >; + +/** The reader's feed in a new group, following the author. */ +const followAuthor = async (groupId: string, userId = readerId) => { + cleanup.feeds.push(`${groupId}:${userId}`); + const feed = client.feeds.feed(groupId, userId); + await retry(() => feed.getOrCreate({ user_id: userId }), { + retryIf: isFeedGroupMissing, + }); + await retry( + () => + client.feeds.follow({ source: feed.feed, target: `user:${authorId}` }), + { retryIf: isFeedGroupMissing }, + ); + return feed; +}; + +/** Reads the feed until all three activities are there in `expected` order. */ +const waitForOrder = ( + feed: StreamFeed, + expected: string[], + request: Omit = {}, + userId = readerId, +) => + retry(async () => { + const response = await feed.getOrCreate({ user_id: userId, ...request }); + const got = order(response); + if (got.join() !== expected.join()) { + throw new Error(`order is ${got.join()}, expected ${expected.join()}`); + } + return response; + }); + +/** The reader's feed in `groupId`, once ranked in `expected` order. */ +const rankedFeed = async (groupId: string, expected: string[]) => { + const feed = await followAuthor(groupId); + return waitForOrder(feed, expected); +}; + +const createdGroups = (spy: { mock: { results: Array<{ value: unknown }> } }) => + Promise.all( + spy.mock.results.map((r) => r.value as Promise), + ); + +describe(DOCS, () => { + // Calls into a new group can reach API nodes that don't know it yet + // (activities in new groups are added with `addActivityOnce`: a retried + // addActivity can add them twice). + beforeEach(() => + retryOnFeedGroupMissing(client, [ + 'getOrCreateFeed', + 'follow', + 'markActivity', + 'activityFeedback', + ]), + ); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + beforeAll(async () => { + authorId = await createUser('author'); + readerId = await createUser('reader'); + const fanId = await createUser('fan'); + cleanup.feeds.push(`user:${authorId}`, `user:${fanId}`); + await client.feeds + .feed('user', authorId) + .getOrCreate({ user_id: authorId }); + await client.feeds.feed('user', fanId).getOrCreate({ user_id: fanId }); + const post = async ( + name: string, + custom: Record, + tag: string, + ) => { + const { activity } = await client.feeds.addActivity({ + type: 'post', + feeds: [`user:${authorId}`], + text: name, + custom, + interest_tags: [tag], + user_id: authorId, + }); + names.set(activity.id, name); + return activity.id; + }; + const a0 = await post('a0', { stock: 'tesla', stocks: [] }, 'exercise'); + const a1 = await post( + 'a1', + { isBoosted: true, stock: 'nokia', stocks: ['microsoft'] }, + 'food', + ); + const a2 = await post( + 'a2', + { stock: 'apple', stocks: ['apple', 'tesla'] }, + 'travel', + ); + const types = ['like', 'love', 'wow', 'haha', 'sad']; + for (const type of types) { + await client.feeds.addActivityReaction({ + activity_id: a0, + type, + user_id: fanId, + }); + } + for (const type of types.slice(0, 4)) { + await client.feeds.addActivityReaction({ + activity_id: a1, + type, + user_id: fanId, + }); + } + // A share: an activity with a2 as parent (in the fan's own feed). + await client.feeds.addActivity({ + type: 'post', + feeds: [`user:${fanId}`], + parent_id: a2, + text: 'shared', + user_id: fanId, + }); + await retry(async () => { + const response = await client.feeds + .feed('user', authorId) + .getOrCreate({ user_id: authorId }); + const got = byName(response); + const popularity = ['a0', 'a1', 'a2'].map((n) => got[n]?.popularity); + if (popularity.join() !== '5,4,3' || got.a2.share_count !== 1) { + throw new Error(`popularity is ${popularity.join()}`); + } + }); + }); + + it('creates a feed group with an expression ranking', async () => { + const myTimelineId = newFeedGroupId('mytimeline'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="" tab="Node.js" index=1 + // COPY: myTimelineId="mytimeline" + const response = await serverClient.feeds.createFeedGroup({ + id: myTimelineId, + ranking: { type: 'expression', score: 'decay_linear(time) * popularity' }, + activity_selectors: [ + { + type: 'following', + }, + ], + }); + // #endregion snippet + + expect(response.feed_group.ranking).toEqual({ + type: 'expression', + score: 'decay_linear(time) * popularity', + }); + // Ranked by popularity (decay is ~1 for new activities), not recency. + const feed = await rankedFeed(myTimelineId, ['a0', 'a1', 'a2']); + const got = byName(feed); + expect(got.a0.score).toBeCloseTo(5, 1); + expect(got.a2.score).toBeCloseTo(3, 1); + }); + + it('updates a feed group', async () => { + // The docs talk about built-in groups, but those are app-wide: the fence + // runs on a uniqueId group. + const feedGroupId = newFeedGroupId('to-update'); + await client.feeds.createFeedGroup({ + id: feedGroupId, + ranking: { type: 'expression', score: 'popularity' }, + }); + const updateFeedGroup = client.feeds.updateFeedGroup.bind(client.feeds); + const update = vi.spyOn(client.feeds, 'updateFeedGroup'); + update.mockImplementation((request) => + retry(() => updateFeedGroup(request), { retryIf: isFeedGroupMissing }), + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="" tab="Node.js" index=2 + // COPY: feedGroupId="" + await client.feeds.updateFeedGroup({ + id: feedGroupId, + // Fields to update + }); + // #endregion snippet + + const { feed_group } = await (update.mock.results[0] + .value as Promise); + expect(feed_group.id).toBe(feedGroupId); + // Updates replace every field: the omitted ranking is cleared. + expect(feed_group.ranking).toBeUndefined(); + }); + + it('ranks by popularity', async () => { + const myId = newFeedGroupId('myid'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="" tab="Node.js" index=3 + // COPY: myId="myid" + await serverClient.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + score: 'popularity', + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(feed_group.ranking?.score).toBe('popularity'); + const feed = await rankedFeed(myId, ['a0', 'a1', 'a2']); + for (const activity of ours(feed)) { + expect(activity.score).toBe(activity.popularity); + } + }); + + it('ranks with custom data and defaults', async () => { + const myId = newFeedGroupId('myid'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Activity data" tab="Node.js" index=1 + // COPY: myId="myid" + await serverClient.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + score: 'popularity * (custom.isBoosted ? 1.5 : 1)', + defaults: { + custom: { + isBoosted: false, + }, + }, + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(feed_group.ranking?.defaults).toEqual({ + custom: { isBoosted: false }, + }); + // a1 (popularity 4) is boosted to 6, above a0 (5). + const feed = await rankedFeed(myId, ['a1', 'a0', 'a2']); + expect(byName(feed).a1.score).toBe(6); + // Without the default, the expression is rejected. + await expect( + client.feeds.createFeedGroup({ + id: newFeedGroupId('no-default'), + ranking: { + type: 'expression', + score: 'popularity * (custom.isBoosted ? 1.5 : 1)', + }, + }), + ).rejects.toThrow('expression references fields without defaults'); + }); + + it('ranks with decay functions', async () => { + const myId = newFeedGroupId('myid'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Parameters" tab="Node.js" index=1 + // COPY: myId="myid" + await serverClient.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + functions: { + simple_gauss: { + base: 'decay_gauss', + scale: '5d', + offset: '1d', + decay: '0.3', + }, + popularity_gauss: { + base: 'decay_gauss', + scale: '100', + offset: '5', + decay: '0.5', + }, + }, + score: 'simple_gauss(time)*popularity_gauss(popularity)', + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(Object.keys(feed_group.ranking?.functions ?? {}).sort()).toEqual([ + 'popularity_gauss', + 'simple_gauss', + ]); + const feed = await followAuthor(myId); + const response = await retry(async () => { + const r = await feed.getOrCreate({ user_id: readerId }); + if (ours(r).length !== 3) throw new Error('activities missing'); + return r; + }); + // (2026-10-16) Every activity scores 0: popularity_gauss has no + // `origin`, which defaults to now (a UNIX timestamp), so popularity is + // never near it. + expect(ours(response).map((a) => a.score)).toEqual([0, 0, 0]); + // simple_gauss alone gives activities younger than the offset a 1. + const timeOnlyId = newFeedGroupId('time-only'); + await client.feeds.createFeedGroup({ + id: timeOnlyId, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + functions: feed_group.ranking?.functions, + score: 'simple_gauss(time)', + }, + }); + const timeOnly = await followAuthor(timeOnlyId); + const timeOnlyResponse = await retry(async () => { + const r = await timeOnly.getOrCreate({ user_id: readerId }); + if (ours(r).length !== 3) throw new Error('activities missing'); + return r; + }); + expect(ours(timeOnlyResponse).map((a) => a.score)).toEqual([1, 1, 1]); + }); + + it('ranks by interest weights', async () => { + const myId = newFeedGroupId('myid'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Interest weights" tab="Node.js" index=1 + // COPY: myId="myid" + await serverClient.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'interest', + score: 'interest_score * popularity', + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(feed_group.ranking?.type).toBe('interest'); + + const timeline = await followAuthor(myId); + // The reader has no stored interests: every interest_score is 0. + const plain = await retry(async () => { + const r = await timeline.getOrCreate({ user_id: readerId }); + if (ours(r).length !== 3) throw new Error('activities missing'); + return r; + }); + expect(ours(plain).map((a) => a.score)).toEqual([0, 0, 0]); + // Settle the ranking with the docs' weights before the region. + await waitForOrder(timeline, ['a2', 'a1', 'a0'], { + interest_weights: { travel: 1, food: 0.5, exercise: -1 }, + }); + // The spy retryOnFeedGroupMissing installed. + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + getOrCreate.mockClear(); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="User interests" tab="Node.js" index=1 + // COPY: readerId="" + await timeline.getOrCreate({ + interest_weights: { + travel: 1, + food: 0.5, + exercise: -1, + }, + user_id: readerId, + }); + // #endregion snippet + + const response = await (getOrCreate.mock.results[0] + .value as Promise); + // travel (a2) > food (a1) > exercise (a0, a negative score). + expect(order(response)).toEqual(['a2', 'a1', 'a0']); + const got = byName(response); + expect(got.a1.score).toBeGreaterThan(0); + expect(got.a0.score).toBeLessThan(0); + }); + + it('ranks by preference score', async () => { + const myId = newFeedGroupId('myid'); + const myIdInterest = newFeedGroupId('myid-interest'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Preference score" tab="Node.js" index=1 + // COPY: myId="myid", myIdInterest="myid_interest" + // Using preference_score with expression type + await serverClient.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + score: 'preference_score * popularity', + }, + }); + + // Using preference_score with interest type + await serverClient.feeds.createFeedGroup({ + id: myIdInterest, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'interest', + score: 'preference_score * interest_score * popularity', + }, + }); + // #endregion snippet + + const groups = await createdGroups(create); + expect(groups.map((g) => g.feed_group.ranking?.type)).toEqual([ + 'expression', + 'interest', + ]); + // A user of their own: activity feedback changes their preferences. + const userId = await createUser('preferences'); + const feed = await followAuthor(myId, userId); + // Without activity feedback preference_score is 0 for every activity. + const before = await retry(async () => { + const r = await feed.getOrCreate({ user_id: userId }); + if (ours(r).length !== 3) throw new Error('activities missing'); + return r; + }); + expect(ours(before).map((a) => a.score)).toEqual([0, 0, 0]); + // "Show more" on a2 (travel) moves it to the top. + await client.feeds.activityFeedback({ + activity_id: byName(before).a2.id, + show_more: true, + user_id: userId, + }); + const after = await waitForOrder(feed, ['a2', 'a1', 'a0'], {}, userId); + expect(byName(after).a2.score).toBeGreaterThan(0); + expect(byName(after).a1.score).toBe(0); + }); + + it('ranks by selector source', async () => { + const selectorTimelineId = newFeedGroupId('selector-timeline'); + const multiSelectorTimelineId = newFeedGroupId('multi-selector-timeline'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Ranking by Selector Source" tab="Node.js" index=1 + // COPY: selectorTimelineId="selector_timeline", multiSelectorTimelineId="multi_selector_timeline" + // Example 1: Simple boost - following activities get 2x multiplier + await serverClient.feeds.createFeedGroup({ + id: selectorTimelineId, + activity_selectors: [ + { type: 'following' }, + { type: 'popular', min_popularity: 1 }, + ], + ranking: { + type: 'expression', + score: 'selector_source == "following" ? popularity * 2 : popularity', + defaults: { + selector_source: '', // Required when using selector_source in expressions + }, + }, + }); + + // Example 2: Multiple selectors with different bonuses + await serverClient.feeds.createFeedGroup({ + id: multiSelectorTimelineId, + activity_selectors: [ + { type: 'following' }, + { type: 'interest' }, + { type: 'popular', min_popularity: 1 }, + ], + ranking: { + type: 'expression', + score: ` + (selector_source == "following" ? 10 : 0) + + (selector_source == "interest" ? 5 : 0) + + popularity + `, + defaults: { + selector_source: '', + }, + }, + }); + // #endregion snippet + + const groups = await createdGroups(create); + expect(groups.map((g) => g.feed_group.activity_selectors?.length)).toEqual([ + 2, 3, + ]); + // The author's activities come from the following selector (the first + // one): their popularity is doubled / gets +10. + const first = await rankedFeed(selectorTimelineId, ['a0', 'a1', 'a2']); + for (const activity of ours(first)) { + expect(activity.selector_source).toBe('following'); + expect(activity.score).toBe(activity.popularity * 2); + } + const second = await rankedFeed(multiSelectorTimelineId, [ + 'a0', + 'a1', + 'a2', + ]); + for (const activity of ours(second)) { + expect(activity.selector_source).toBe('following'); + expect(activity.score).toBe(activity.popularity + 10); + } + }); + + it('ranks read activities lower', async () => { + const rankedTimelineId = newFeedGroupId('ranked-timeline'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Ranking by Read/Seen Status" tab="Node.js" index=1 + // COPY: rankedTimelineId="ranked_timeline" + await serverClient.feeds.createFeedGroup({ + id: rankedTimelineId, + activity_marks: { + track_seen: true, + track_read: true, + }, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + score: 'popularity - (is_read ? 100 : 0)', + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(feed_group.activity_marks).toMatchObject({ track_read: true }); + const feed = await followAuthor(rankedTimelineId); + const unread = await waitForOrder(feed, ['a0', 'a1', 'a2']); + const a0 = byName(unread).a0.id; + await feed.markActivity({ mark_read: [a0], user_id: readerId }); + // The most popular activity drops to the bottom once read. + const response = await waitForOrder(feed, ['a1', 'a2', 'a0']); + expect(byName(response).a0).toMatchObject({ is_read: true, score: -95 }); + }); + + it('ranks unseen activities first', async () => { + const unseenTimelineId = newFeedGroupId('unseen-timeline'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Ranking by Read/Seen Status" tab="Node.js" index=2 + // COPY: unseenTimelineId="unseen_timeline" + await serverClient.feeds.createFeedGroup({ + id: unseenTimelineId, + activity_marks: { + track_seen: true, + track_read: true, + }, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + score: 'is_seen ? 0 : 100', + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(feed_group.ranking?.score).toBe('is_seen ? 0 : 100'); + const feed = await followAuthor(unseenTimelineId); + const before = await retry(async () => { + const r = await feed.getOrCreate({ user_id: readerId }); + if (ours(r).length !== 3) throw new Error('activities missing'); + return r; + }); + expect(ours(before).map((a) => a.score)).toEqual([100, 100, 100]); + const a1 = byName(before).a1.id; + await feed.markActivity({ mark_seen: [a1], user_id: readerId }); + const response = await retry(async () => { + const r = await feed.getOrCreate({ user_id: readerId }); + if (order(r)[2] !== 'a1') throw new Error('a1 not last yet'); + return r; + }); + expect(byName(response).a1).toMatchObject({ is_seen: true, score: 0 }); + }); + + it('ranks with external data', async () => { + const myId = newFeedGroupId('myid'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Accessing external data in ranking expressions" tab="Node.js" index=1 + // COPY: myId="myid" + await serverClient.feeds.createFeedGroup({ + id: myId, + ranking: { + type: 'expression', + score: + 'popularity * external.popularity_multiplier + share_count * external.share_multiplier', + defaults: { + external: { + // Provide default values if external data isn't provided when reading the feed + popularity_multiplier: 1, + share_multiplier: 1, + }, + }, + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(feed_group.ranking?.defaults).toEqual({ + external: { popularity_multiplier: 1, share_multiplier: 1 }, + }); + // No selectors: the group ranks the feed's own activities + // (current_feed). b0 has popularity 5, b1 one share (popularity 3). + cleanup.feeds.push(`${myId}:${readerId}`); + const timeline = client.feeds.feed(myId, readerId); + await retry(() => timeline.getOrCreate({ user_id: readerId }), { + retryIf: isFeedGroupMissing, + }); + const post = async (name: string) => { + const activityId = await addActivityOnce(client, { + type: 'post', + feeds: [timeline.feed], + text: name, + user_id: authorId, + }); + names.set(activityId, name); + return activityId; + }; + const b0 = await post('b0'); + const b1 = await post('b1'); + for (const type of ['like', 'love', 'wow', 'haha', 'sad']) { + await client.feeds.addActivityReaction({ + activity_id: b0, + type, + user_id: readerId, + }); + } + await addActivityOnce(client, { + type: 'post', + feeds: [timeline.feed], + parent_id: b1, + text: 'shared', + user_id: authorId, + }); + // With the defaults: b0 5, b1 3 + 1 share. + const plain = await waitForOrder(timeline, ['b0', 'b1']); + expect(byName(plain).b1.score).toBe(4); + await waitForOrder(timeline, ['b1', 'b0'], { + external_ranking: { popularity_multiplier: 1, share_multiplier: 100 }, + }); + // The spy retryOnFeedGroupMissing installed. + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + getOrCreate.mockClear(); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Providing external data when reading the feed" tab="Node.js" index=1 + // COPY: readerId="" + await timeline.getOrCreate({ + external_ranking: { + popularity_multiplier: 1, // normal weight for popularity + share_multiplier: 100, // very high boost for share_count + }, + user_id: readerId, + }); + // #endregion snippet + + const response = await (getOrCreate.mock.results[0] + .value as Promise); + // The shared activity jumps to the top: 3 + 1 * 100. + expect(order(response)).toEqual(['b1', 'b0']); + expect(byName(response).b1.score).toBe(103); + }); + + it('ranks with map_lookup', async () => { + const myId = newFeedGroupId('myid'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Arrays and Maps" tab="Node.js" index=1 + // COPY: myId="myid" + await serverClient.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + score: 'popularity + map_lookup(stock, external.stock_boosts)', + defaults: { + stock: '', + external: { + stock_boosts: {}, + }, + }, + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(feed_group.ranking?.defaults).toEqual({ + stock: '', + external: { stock_boosts: {} }, + }); + const timeline = await followAuthor(myId); + await waitForOrder(timeline, ['a0', 'a1', 'a2']); + // The spy retryOnFeedGroupMissing installed. + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + getOrCreate.mockClear(); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Arrays and Maps" tab="Node.js" index=2 + // COPY: readerId="" + await timeline.getOrCreate({ + external_ranking: { + stock_boosts: { + apple: 0.9, + tesla: 0.5, + microsoft: 0.3, + }, + }, + user_id: readerId, + }); + // #endregion snippet + + const response = await (getOrCreate.mock.results[0] + .value as Promise); + // a0 (tesla) 5 + 0.5, a1 (nokia, not in the map) 4 + 0, a2 (apple) 3 + 0.9. + const got = byName(response); + expect(got.a0.score).toBeCloseTo(5.5, 5); + expect(got.a1.score).toBeCloseTo(4, 5); + expect(got.a2.score).toBeCloseTo(3.9, 5); + }); + + it('ranks with sum_map_lookup', async () => { + const myId = newFeedGroupId('myid'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Arrays and Maps" tab="Node.js" index=3 + // COPY: myId="myid" + await serverClient.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + score: 'popularity + sum_map_lookup(stocks, external.stock_boosts)', + defaults: { + stocks: [], + external: { + stock_boosts: {}, + }, + }, + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(feed_group.ranking?.defaults).toEqual({ + stocks: [], + external: { stock_boosts: {} }, + }); + const timeline = await followAuthor(myId); + // a0 (no stocks) 5, a1 (microsoft) 4 + 2, a2 (apple, tesla) 3 + 15. + await waitForOrder(timeline, ['a2', 'a1', 'a0'], { + external_ranking: { + stock_boosts: { apple: 10.0, tesla: 5.0, microsoft: 2.0 }, + }, + }); + // The spy retryOnFeedGroupMissing installed. + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + getOrCreate.mockClear(); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Arrays and Maps" tab="Node.js" index=4 + // COPY: readerId="" + await timeline.getOrCreate({ + external_ranking: { + stock_boosts: { + apple: 10.0, + tesla: 5.0, + microsoft: 2.0, + }, + }, + user_id: readerId, + }); + + // score = popularity + 10.0 + 5.0 = popularity + 15.0 (for activity with stocks: ['apple', 'tesla']) + // #endregion snippet + + const response = await (getOrCreate.mock.results[0] + .value as Promise); + expect(order(response)).toEqual(['a2', 'a1', 'a0']); + const got = byName(response); + expect(got.a2.score).toBe(18); + expect(got.a1.score).toBe(6); + expect(got.a0.score).toBe(5); + }); + + it('ranks with in_array', async () => { + const myId = newFeedGroupId('myid'); + const create = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Arrays and Maps" tab="Node.js" index=5 + // COPY: myId="myid" + await serverClient.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'following' }], + ranking: { + type: 'expression', + score: 'popularity + in_array(stock, external.watchlist) * 10', + defaults: { + stock: '', + external: { + watchlist: [], + }, + }, + }, + }); + // #endregion snippet + + const [{ feed_group }] = await createdGroups(create); + expect(feed_group.ranking?.defaults).toEqual({ + stock: '', + external: { watchlist: [] }, + }); + const timeline = await followAuthor(myId); + // a0 (tesla) 5 + 10, a1 (nokia) 4, a2 (apple) 3 + 10. + await waitForOrder(timeline, ['a0', 'a2', 'a1'], { + external_ranking: { watchlist: ['apple', 'tesla', 'microsoft'] }, + }); + // The spy retryOnFeedGroupMissing installed. + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + getOrCreate.mockClear(); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Arrays and Maps" tab="Node.js" index=6 + // COPY: readerId="" + await timeline.getOrCreate({ + external_ranking: { + watchlist: ['apple', 'tesla', 'microsoft'], + }, + user_id: readerId, + }); + + // activities with stocks in the watchlist get a +10 boost, others get no boost. + // #endregion snippet + + const response = await (getOrCreate.mock.results[0] + .value as Promise); + expect(order(response)).toEqual(['a0', 'a2', 'a1']); + const got = byName(response); + expect(got.a0.score).toBe(15); + expect(got.a2.score).toBe(13); + expect(got.a1.score).toBe(4); + }); + + it('inspects ranking variables', async () => { + // The docs' "sara": a timeline that follows the author. + const saraId = await createUser('sara'); + await followAuthor('timeline', saraId); + await retry(async () => { + const r = await client.feeds + .feed('timeline', saraId) + .getOrCreate({ user_id: saraId }); + if (!r.activities.length) throw new Error('timeline still empty'); + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/04-custom_ranking.md" heading="Inspecting ranking variables" tab="Node.js" index=1 + // COPY: saraId="sara" + const feed = client.feeds.feed('timeline', saraId); + const response = await feed.getOrCreate({ user_id: saraId }); + + console.log(response.activities[0].score); + console.log(response.activities[0].reaction_count); + console.log(response.activities[0].popularity); + // #endregion snippet + + const first = response.activities[0]; + expect(names.has(first.id)).toBe(true); + expect(log.mock.calls).toEqual([ + [first.score], + [first.reaction_count], + [first.popularity], + ]); + expect(typeof first.score).toBe('number'); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/05-activity_selectors.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/05-activity_selectors.test.ts new file mode 100644 index 0000000..4663715 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/05-activity_selectors.test.ts @@ -0,0 +1,618 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + GetOrCreateFeedRequest, + GetOrCreateFeedResponse, + UpdateFeedGroupResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + addActivityOnce, + isFeedGroupMissing, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +// Feed groups are app-wide: every group a fence creates gets a uniqueId id. +const newFeedGroupId = (name: string) => { + const id = uniqueId(name); + cleanup.feedGroups.push(id); + return id; +}; + +const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; +}; + +const amsterdam = { lat: 52.373558, lng: 4.885261 }; +const withinAmsterdam = { + within_bounds: { + $eq: { + ne_lat: 52.43017, + ne_lng: 4.924821, + sw_lat: 52.334272, + sw_lng: 4.822116, + }, + }, +}; + +/* + * Shared data, all public (the author's `user` feed and a `drafts` feed): + * + * | activity | feed | type | popularity | location | filter_tags | interest_tags | search_data | + * | -------- | ------------- | ------- | ----------------- | --------- | ------------- | ------------- | ------------------ | + * | hot | user:author | post | 70 (35 comments) | Amsterdam | green, orange | hiking | workout_type: hike | + * | warm | user:author | post | 10 (5 comments) | Amsterdam | green | cooking | workout_type: run | + * | workout | user:author | workout | 0 | - | - | hiking | - | + * | draft | drafts:author | post | 10 (5 comments) | - | - | - | - | + * + * The reader is interested in hiking. + */ +let authorId: string; +let readerId: string; +let draftsId: string; +const names = new Map(); + +/** Our activities in the feed, by name, in feed order. */ +const ours = (response: GetOrCreateFeedResponse) => + response.activities + .filter((a) => names.has(a.id)) + .map((a) => names.get(a.id)); + +/** The reader's feed in `groupId` (created once, registered for cleanup). */ +const readerFeed = (groupId: string) => { + cleanup.feeds.push(`${groupId}:${readerId}`); + return client.feeds.feed(groupId, readerId); +}; + +/** Reads the reader's feed until `check` passes on our activities. */ +const readUntil = ( + groupId: string, + check: (got: Array) => boolean, + request: Omit = {}, +) => + retry(async () => { + const response = await client.feeds + .feed(groupId, readerId) + .getOrCreate({ user_id: readerId, ...request }); + const got = ours(response); + if (!check(got)) throw new Error(`got ${got.join()}`); + return response; + }); + +const has = + (...expected: string[]) => + (got: Array) => + expected.every((n) => got.includes(n)); +const exactly = + (...expected: string[]) => + (got: Array) => + [...got].sort().join() === [...expected].sort().join(); + +const followAuthor = (groupId: string) => + retry( + () => + client.feeds.follow({ + source: `${groupId}:${readerId}`, + target: `user:${authorId}`, + }), + { retryIf: isFeedGroupMissing }, + ); + +const updatedGroup = (spy: { mock: { results: Array<{ value: unknown }> } }) => + spy.mock.results[0].value as Promise; + +describe(DOCS, () => { + // Calls into a new group can reach API nodes that don't know it yet + // (activities are added with `addActivityOnce`: a retried addActivity can + // add them twice). + beforeEach(() => + retryOnFeedGroupMissing(client, [ + 'getOrCreateFeed', + 'follow', + 'updateFeedGroup', + ]), + ); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + beforeAll(async () => { + authorId = await createUser('author'); + readerId = await createUser('reader'); + const fanId = await createUser('fan'); + draftsId = newFeedGroupId('drafts'); + await client.feeds.createFeedGroup({ id: draftsId }); + cleanup.feeds.push(`user:${authorId}`, `${draftsId}:${authorId}`); + await client.feeds + .feed('user', authorId) + .getOrCreate({ user_id: authorId }); + await retry( + () => + client.feeds + .feed(draftsId, authorId) + .getOrCreate({ user_id: authorId }), + { retryIf: isFeedGroupMissing }, + ); + const post = async ( + name: string, + comments: number, + request: Partial[0]>, + ) => { + // The drafts group is new: retry until every API node knows it. + const activityId = await addActivityOnce(client, { + type: 'post', + feeds: [`user:${authorId}`], + text: name, + user_id: authorId, + ...request, + }); + names.set(activityId, name); + if (comments > 0) { + await client.feeds.addCommentsBatch({ + comments: Array.from({ length: comments }, (_, i) => ({ + object_id: activityId, + object_type: 'activity', + comment: `comment ${i}`, + user_id: fanId, + })), + }); + } + return activityId; + }; + const hot = await post('hot', 35, { + location: amsterdam, + filter_tags: ['green', 'orange'], + interest_tags: ['hiking'], + search_data: { workout_type: 'hike' }, + }); + const warm = await post('warm', 5, { + location: amsterdam, + filter_tags: ['green'], + interest_tags: ['cooking'], + search_data: { workout_type: 'run' }, + }); + await post('workout', 0, { type: 'workout', interest_tags: ['hiking'] }); + const draft = await post('draft', 5, { + feeds: [`${draftsId}:${authorId}`], + }); + await retry(async () => { + const popularity = await Promise.all( + [hot, warm, draft].map( + async (id) => + (await client.feeds.getActivity({ id })).activity.popularity, + ), + ); + if (popularity.join() !== '70,10,10') { + throw new Error(`popularity is ${popularity.join()}`); + } + }); + await client.feeds.upsertUserInterests({ + user_id: readerId, + interests: [{ tag: 'hiking', weight: 1 }], + }); + }); + + it('creates a group with the current feed selector', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Example" tab="Node.js" index=1 + // COPY: myId="myid" + const response = await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'current_feed', + // Optionally provide cutoff window + cutoff_window: '10d', + }, + ], + }); + // #endregion snippet + + expect(response.feed_group.activity_selectors).toMatchObject([ + { type: 'current_feed', cutoff_window: '10d' }, + ]); + // The feed shows its own activities only. + const feed = readerFeed(myId); + await feed.getOrCreate({ user_id: readerId }); + await followAuthor(myId); + const ownId = await addActivityOnce(client, { + type: 'post', + feeds: [feed.feed], + text: 'own', + user_id: readerId, + }); + names.set(ownId, 'own'); + await readUntil(myId, exactly('own')); + }); + + it('creates a group with the following selector', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Example" tab="Node.js" index=2 + // COPY: myId="myid" + const response = await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'following', + // Optionally provide cutoff window + cutoff_window: '10d', + }, + ], + }); + // #endregion snippet + + expect(response.feed_group.activity_selectors).toMatchObject([ + { type: 'following', cutoff_window: '10d' }, + ]); + await readerFeed(myId).getOrCreate({ user_id: readerId }); + await followAuthor(myId); + // The author's user feed activities, not the drafts feed. + await readUntil(myId, exactly('hot', 'warm', 'workout')); + }); + + it('creates a group with the popular selector', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Scope" tab="Node.js" index=1 + // COPY: myId="myid" + const response = await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'popular', + // Optional parameters + min_popularity: 70, + cutoff_window: '10d', + }, + ], + }); + // #endregion snippet + + expect(response.feed_group.activity_selectors).toMatchObject([ + { type: 'popular', min_popularity: 70, cutoff_window: '10d' }, + ]); + // Only hot reaches a popularity of 70; nobody is followed. + readerFeed(myId); + const feed = await readUntil(myId, has('hot')); + expect(ours(feed)).toEqual(['hot']); + }); + + it('creates a group with the proximity selector and reads it', async () => { + const myId = newFeedGroupId('myid'); + const feed = readerFeed(myId); + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + getOrCreate.mockClear(); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Example" tab="Node.js" index=3 + // COPY: myId="myid", readerId="" + const response = await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'proximity', + // Optional parameters + min_popularity: 40, + cutoff_window: '1d', + }, + ], + }); + + // Provide filter when reading the feed + await feed.getOrCreate({ + filter: { + within_bounds: { + $eq: { + ne_lat: 52.43017, + ne_lng: 4.924821, + sw_lat: 52.334272, + sw_lng: 4.822116, + }, + }, + }, + user_id: readerId, + }); + + // Use either within_bounds or near filter + await feed.getOrCreate({ + filter: { + near: { $eq: { lat: 52.373558, lng: 4.885261, distance: 10 } }, + }, + user_id: readerId, + }); + // #endregion snippet + + expect(response.feed_group.activity_selectors).toMatchObject([ + { type: 'proximity', min_popularity: 40, cutoff_window: '1d' }, + ]); + const reads = await Promise.all( + getOrCreate.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(reads).toHaveLength(2); + // hot and warm are both in Amsterdam, only hot reaches 40. + for (const read of reads) expect(ours(read)).toEqual(['hot']); + // Without a location filter the read is rejected. + await expect(feed.getOrCreate({ user_id: readerId })).rejects.toThrow( + 'no near or within_bounds filter provided', + ); + }); + + it('creates a group with the interest selector', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Examples" tab="Node.js" index=1 + // COPY: myId="myid" + const response = await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'interest', + // Optional parameters + sort: [{ field: 'popularity', direction: -1 }], + filter: { + activity_type: 'post', + }, + }, + ], + }); + // #endregion snippet + + expect(response.feed_group.activity_selectors).toMatchObject([ + { + type: 'interest', + sort: [{ field: 'popularity', direction: -1 }], + filter: { activity_type: 'post' }, + }, + ]); + // The reader likes hiking: hot (a post), not workout (another type) or + // warm (cooking). + readerFeed(myId); + const feed = await readUntil(myId, has('hot')); + expect(ours(feed)).toEqual(['hot']); + }); + + it('creates a group with the query selector', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Example" tab="Node.js" index=4 + // COPY: myId="myid" + const response = await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'query', + filter: { search_data: { $contains: { workout_type: 'hike' } } }, + // Optional parameters + sort: [{ field: 'created_at', direction: -1 }], + }, + ], + }); + // #endregion snippet + + expect(response.feed_group.activity_selectors).toMatchObject([ + { + type: 'query', + filter: { search_data: { $contains: { workout_type: 'hike' } } }, + }, + ]); + readerFeed(myId); + const feed = await readUntil(myId, has('hot')); + expect(ours(feed)).toEqual(['hot']); + }); + + it('creates a group with the follow suggestion selector', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Example" tab="Node.js" index=5 + // COPY: myId="myid" + const response = await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'follow_suggestion', + // Optional parameters + min_popularity: 10, + cutoff_window: '10d', + params: { + activities_per_feed: 3, + max_suggested_feeds: 15, + min_feed_score: 0.4, + }, + }, + ], + }); + // #endregion snippet + + expect(response.feed_group.activity_selectors).toMatchObject([ + { + type: 'follow_suggestion', + min_popularity: 10, + cutoff_window: '10d', + params: { + activities_per_feed: 3, + max_suggested_feeds: 15, + min_feed_score: 0.4, + }, + }, + ]); + // Suggestions depend on app-wide data: only check that the feed reads. + const { activities } = await readerFeed(myId).getOrCreate({ + user_id: readerId, + }); + expect(Array.isArray(activities)).toBe(true); + }); + + it('filters with $and', async () => { + const followingId = newFeedGroupId('following'); + await client.feeds.createFeedGroup({ + id: followingId, + activity_selectors: [{ type: 'following' }], + }); + await readerFeed(followingId).getOrCreate({ user_id: readerId }); + await followAuthor(followingId); + await readUntil(followingId, exactly('hot', 'warm', 'workout')); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Filters" tab="Node.js" index=1 + // Get all the activities where filter tags contain both "green" and "orange" + const filter = { + $and: [{ filter_tags: ['green'] }, { filter_tags: ['orange'] }], + }; + // #endregion snippet + + // Only hot has both tags (warm has just green). + await readUntil(followingId, exactly('hot'), { filter }); + }); + + it('scopes a selector to feed groups', async () => { + const myId = newFeedGroupId('myid'); + await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'popular' }], + }); + readerFeed(myId); + // Without a scope the popular selector (min popularity 5) also picks the + // draft. + await readUntil(myId, exactly('hot', 'warm', 'draft')); + const update = vi.spyOn(client.feeds, 'updateFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Feed group scope" tab="Node.js" index=1 + // COPY: myId="myid", draftsId="drafts" + await client.feeds.updateFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'popular', + feed_groups: { exclude: [draftsId] }, + }, + ], + }); + // #endregion snippet + + const { feed_group } = await updatedGroup(update); + expect(feed_group.activity_selectors).toMatchObject([ + { type: 'popular', feed_groups: { exclude: [draftsId] } }, + ]); + await readUntil(myId, exactly('hot', 'warm')); + }); + + it('sets a ranking candidate limit', async () => { + const myId = newFeedGroupId('myid'); + await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'popular' }], + }); + const update = vi.spyOn(client.feeds, 'updateFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Ranking candidate limit" tab="Node.js" index=1 + // COPY: myId="myid" + await client.feeds.updateFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'popular', + ranking_candidate_limit: 200, + }, + ], + }); + // #endregion snippet + + const { feed_group } = await updatedGroup(update); + expect(feed_group.activity_selectors).toMatchObject([ + { type: 'popular', ranking_candidate_limit: 200 }, + ]); + readerFeed(myId); + await readUntil(myId, has('hot', 'warm', 'draft')); + // The selectors that don't support it reject it. + await expect( + client.feeds.updateFeedGroup({ + id: myId, + activity_selectors: [ + { type: 'following', ranking_candidate_limit: 200 }, + ], + }), + ).rejects.toThrow('ranking_candidate_limit'); + }); + + it('sets a time budget', async () => { + const myId = newFeedGroupId('myid'); + await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [{ type: 'proximity' }], + }); + const update = vi.spyOn(client.feeds, 'updateFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Time budget" tab="Node.js" index=1 + // COPY: myId="myid" + await client.feeds.updateFeedGroup({ + id: myId, + activity_selectors: [ + { + type: 'proximity', + time_budget_ms: 150, + }, + ], + }); + // #endregion snippet + + const { feed_group } = await updatedGroup(update); + expect(feed_group.activity_selectors).toMatchObject([ + { type: 'proximity', time_budget_ms: 150 }, + ]); + readerFeed(myId); + await readUntil(myId, exactly('hot', 'warm'), { filter: withinAmsterdam }); + }); + + it('combines selectors', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/05-activity_selectors.md" heading="Combining selectors" tab="Node.js" index=1 + // COPY: myId="myid" + const response = await client.feeds.createFeedGroup({ + id: myId, + activity_selectors: [ + { type: 'popular' }, + { type: 'following' }, + { type: 'interest' }, + ], + + ranking: { type: 'expression', score: 'decay_linear(time) * popularity' }, + }); + // #endregion snippet + + expect(response.feed_group.activity_selectors?.map((s) => s.type)).toEqual([ + 'popular', + 'following', + 'interest', + ]); + await readerFeed(myId).getOrCreate({ user_id: readerId }); + await followAuthor(myId); + // popular: hot, warm, draft; following: hot, warm, workout; interest: + // hot, workout. Ranked by popularity: hot (70) first, workout (0) last. + const feed = await readUntil(myId, (got) => { + return got.length === 4 && got[0] === 'hot' && got[3] === 'workout'; + }); + expect([...ours(feed)].sort()).toEqual(['draft', 'hot', 'warm', 'workout']); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/06-notification_feeds.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/06-notification_feeds.test.ts new file mode 100644 index 0000000..916071e --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/06-notification_feeds.test.ts @@ -0,0 +1,831 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { UpdateFeedGroupResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + addActivityOnce, + copyFeedGroup, + isFeedGroupMissing, + retryOnFeedGroupMissing, + waitForNotificationTypes, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +// Feed groups are app-wide: every group a fence creates gets a uniqueId id. +const newFeedGroupId = (name: string) => { + const id = uniqueId(name); + cleanup.feedGroups.push(id); + return id; +}; + +const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; +}; + +/** Creates (and registers) a feed. `notification` feeds aren't created automatically. */ +const createFeed = async (group: string, id: string) => { + cleanup.feeds.push(`${group}:${id}`); + const feed = client.feeds.feed(group, id); + await retry(() => feed.getOrCreate({ user_id: id }), { + retryIf: isFeedGroupMissing, + }); + return feed; +}; + +/** + * A new group isn't known to every API node at once, and an `addActivity` + * retried on "not found" can add the activity twice (2026-10-16): wait until + * several reads in a row find the group before adding activities to it. + */ +const waitForFeedGroup = (group: string, id: string) => + retry( + async () => { + for (let i = 0; i < 5; i++) { + await client.feeds.feed(group, id).getOrCreate({ user_id: id }); + } + }, + { retryIf: isFeedGroupMissing }, + ); + +describe(DOCS, () => { + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + describe('notification feed groups', () => { + it('creates a notification feed group', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Creating notification feeds" tab="Node.js" index=1 + // COPY: myId="myid" + const myNotificationGrpup = await serverClient.feeds.createFeedGroup({ + id: myId, + // Group by activity type and day + aggregation: { format: '{{ type }}-{{ time.strftime("%Y-%m-%d") }}' }, + // Enable notification tracking + notification: { + track_read: true, + track_seen: true, + }, + }); + // #endregion snippet + + const group = myNotificationGrpup.feed_group; + expect(group.id).toBe(myId); + expect(group.aggregation?.format).toBe( + '{{ type }}-{{ time.strftime("%Y-%m-%d") }}', + ); + expect(group.notification).toMatchObject({ + track_read: true, + track_seen: true, + }); + }); + + it('adds notification activities manually', async () => { + const user123 = await createUser('user-123'); + const actorId = await createUser('actor'); + // The docs' custom "alerts" notification feed group. + const alertsGroup = newFeedGroupId('alerts'); + await client.feeds.createFeedGroup({ + id: alertsGroup, + notification: { track_read: true, track_seen: true }, + }); + await createFeed('notification', user123); + await createFeed(alertsGroup, user123); + const userNotificationFid = `notification:${user123}`; + const alertsFid = `${alertsGroup}:${user123}`; + await waitForFeedGroup(alertsGroup, user123); + const addActivity = vi.spyOn(client.feeds, 'addActivity'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Manual Activity Addition to Notification Feeds" tab="Node.js" index=1 + // COPY: userNotificationFid="notification:user-123", alertsFid="alerts:user-123", actorId="user_id" + // Add a custom notification directly to a user's notification feed + await serverClient.feeds.addActivity({ + feeds: [userNotificationFid], // Target user's notification feed + type: 'milestone', // Custom activity type + text: "You've reached 1000 followers!", + user_id: actorId, + 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: actorId, + }); + // #endregion snippet + + const notification = await retry(async () => { + const response = await client.feeds + .feed('notification', user123) + .getOrCreate({ user_id: user123 }); + expect(response.notification_status?.unread).toBe(1); + return response; + }); + const [milestone] = notification.aggregated_activities[0].activities; + expect(milestone).toMatchObject({ + type: 'milestone', + text: "You've reached 1000 followers!", + custom: { milestone_type: 'followers', count: 1000 }, + }); + expect(milestone.user.id).toBe(actorId); + + const alerts = await retry( + () => + client.feeds + .feed(alertsGroup, user123) + .getOrCreate({ user_id: user123 }), + { retryIf: isFeedGroupMissing }, + ); + expect(alerts.activities.map((a) => [a.type, a.text])).toEqual([ + ['system_alert', 'Your subscription expires in 3 days'], + ]); + expect(alerts.notification_status?.unread).toBe(1); + expect(addActivity).toHaveBeenCalledTimes(2); + }); + }); + + describe('built-in notification feed', () => { + /** + * The docs' scene: Eric's timeline, Jane's user feed with an activity, and + * Sara's comment on it. Jane and Sara have notification feeds. + */ + const createScene = async () => { + const ericId = await createUser('eric'); + const janeId = await createUser('jane'); + const saraId = await createUser('sara'); + await createFeed('notification', janeId); + await createFeed('notification', saraId); + await createFeed('user', ericId); + const ericTimeline = await createFeed('timeline', ericId); + const janeFeed = await createFeed('user', janeId); + const { activity: janeActivity } = await client.feeds.addActivity({ + type: 'post', + feeds: [janeFeed.feed], + text: "Jane's post", + user_id: janeId, + }); + const { comment: saraComment } = await client.feeds.addComment({ + object_id: janeActivity.id, + object_type: 'activity', + comment: "Sara's comment", + user_id: saraId, + }); + return { + ericId, + janeId, + saraId, + ericTimeline, + janeFeed, + janeActivity, + saraComment, + }; + }; + + it('creates notification activities', async () => { + const { + ericId, + janeId, + saraId, + ericTimeline, + janeFeed, + janeActivity, + saraComment, + } = await createScene(); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Creating notification activities" tab="Node.js" index=1 + // COPY: ericId="eric" + // Eric follows Jane + await serverClient.feeds.follow({ + source: ericTimeline.feed, + target: janeFeed.feed, + // When true Jane's notification feed will be updated with follow activity + create_notification_activity: true, + copy_custom_to_notification: true, // Copy follow custom data to the notification activity + }); + + // Eric comments on Jane's activity + await serverClient.feeds.addComment({ + comment: 'Agree!', + object_id: janeActivity.id, + object_type: 'activity', + // When true Jane's notification feed will be updated with comment activity + create_notification_activity: true, + copy_custom_to_notification: true, // Copy comment custom data to the notification activity + user_id: ericId, + }); + + // Eric reacts to Jane's activity + await serverClient.feeds.addReaction({ + activity_id: janeActivity.id, + // When true Jane's notification feed will be updated with reaction activity + type: 'like', + create_notification_activity: true, + copy_custom_to_notification: true, // Copy reaction custom data to the notification activity + user_id: ericId, + }); + + // Eric reacts to a comment posted to Jane's activity by Sara + await serverClient.feeds.addCommentReaction({ + id: saraComment.id, + type: 'like', + // When true Sara's notification feed will be updated with comment reaction activity + create_notification_activity: true, + copy_custom_to_notification: true, // Copy comment reaction custom data to the notification activity + user_id: ericId, + }); + // #endregion snippet + + // Reaction notifications are created in the background: retry. + await waitForNotificationTypes(client, janeId, [ + 'follow', + 'comment', + 'reaction', + ]); + await waitForNotificationTypes(client, saraId, ['comment_reaction']); + const { aggregated_activities } = await client.feeds + .feed('notification', janeId) + .getOrCreate({ user_id: janeId }); + for (const group of aggregated_activities) { + expect(group.activities[0].user.id).toBe(ericId); + } + }); + + it('updates mentions in activities and comments', async () => { + const aliceId = await createUser('alice'); + const bobId = await createUser('bob'); + const charlieId = await createUser('charlie'); + await createFeed('notification', bobId); + await createFeed('notification', charlieId); + const aliceFeed = await createFeed('user', aliceId); + const { activity } = await client.feeds.addActivity({ + type: 'post', + feeds: [aliceFeed.feed], + text: 'Check this out', + user_id: aliceId, + }); + const activityId = activity.id; + const { comment } = await client.feeds.addComment({ + object_id: activityId, + object_type: 'activity', + comment: 'A comment', + user_id: aliceId, + }); + const commentId = comment.id; + // Bob's notifications after the first update (before the mention is removed). + let bobAfterMention: string[] = []; + const updateComment = client.feeds.updateComment.bind(client.feeds); + vi.spyOn(client.feeds, 'updateComment').mockImplementation( + async (request) => { + bobAfterMention = await waitForNotificationTypes(client, bobId, [ + 'mention', + ]); + return updateComment(request); + }, + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Updating mentions in activities and comments" tab="Node.js" index=1 + // COPY: bobId="bob", charlieId="charlie", aliceId="alice" + // Alice updates her activity to mention Bob + await serverClient.feeds.updateActivity({ + id: activityId, + text: 'Hey @Bob check this out!', + mentioned_user_ids: [bobId], + handle_mention_notifications: true, // When true, Bob will receive a mention notification + user_id: aliceId, + }); + + // Alice updates her comment to mention Charlie + await serverClient.feeds.updateComment({ + id: commentId, + comment: 'Hey @Charlie!', + mentioned_user_ids: [charlieId], + handle_mention_notifications: true, // When true, Charlie will receive a comment_mention notification + user_id: aliceId, + }); + + // Alice removes mentions from her activity + await serverClient.feeds.updateActivity({ + id: activityId, + text: 'Updated text without mentions', + mentioned_user_ids: [], + handle_mention_notifications: true, // When true, mention notifications for removed users are deleted + user_id: aliceId, + }); + // #endregion snippet + + expect(bobAfterMention).toEqual(['mention']); + await waitForNotificationTypes(client, charlieId, ['comment_mention']); + await waitForNotificationTypes(client, bobId, []); + }); + + it('polls the reaction notification task', async () => { + const { ericId, janeId, janeActivity } = await createScene(); + const reaction = await client.feeds.addActivityReaction({ + activity_id: janeActivity.id, + type: 'like', + create_notification_activity: true, + user_id: ericId, + }); + expect(reaction.notification_accepted).toBe(true); + expect(reaction.notification_created).toBe(true); + expect(typeof reaction.notification_task_id).toBe('string'); + // The docs' placeholder for an async API response, with the reaction's + // notification_task_id as task_id (as the text above the fence says). + const _ = { task_id: reaction.notification_task_id ?? '' }; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Deprecated: `notification_created`" tab="Node.js" index=1 + // Example of monitoring the status of an async task + // The logic is same for all async tasks + const response = _; // Result of a Stream async API request + + // You need 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.mock.calls[0]).toEqual([taskResponse.status === 'completed']); + await waitForNotificationTypes(client, janeId, ['reaction']); + }); + + it('deletes notification activities', async () => { + const { + ericId, + janeId, + saraId, + ericTimeline, + janeFeed, + janeActivity, + saraComment, + } = await createScene(); + // Everything the fence deletes, created with notifications. + await client.feeds.follow({ + source: ericTimeline.feed, + target: janeFeed.feed, + create_notification_activity: true, + }); + const { comment } = await client.feeds.addComment({ + comment: 'Agree!', + object_id: janeActivity.id, + object_type: 'activity', + create_notification_activity: true, + user_id: ericId, + }); + const commentId = comment.id; + await client.feeds.addActivityReaction({ + activity_id: janeActivity.id, + type: 'like', + create_notification_activity: true, + user_id: ericId, + }); + await client.feeds.addCommentReaction({ + id: saraComment.id, + type: 'like', + create_notification_activity: true, + user_id: ericId, + }); + // Eric's activities mentioning Jane. + const [activityId, activityId1, activityId2] = await Promise.all( + ['one', 'two', 'three'].map(async (text) => { + const { activity } = await client.feeds.addActivity({ + type: 'post', + feeds: [`user:${ericId}`], + text: `@Jane ${text}`, + mentioned_user_ids: [janeId], + create_notification_activity: true, + user_id: ericId, + }); + return activity.id; + }), + ); + await waitForNotificationTypes(client, janeId, [ + 'follow', + 'comment', + 'reaction', + 'mention', + 'mention', + 'mention', + ]); + await waitForNotificationTypes(client, saraId, ['comment_reaction']); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Deleting notification activities" tab="Node.js" index=1 + // COPY: ericId="eric" + // Eric unfollows Jane + await serverClient.feeds.unfollow({ + source: ericTimeline.feed, + target: janeFeed.feed, + // When true the corresponding notification activity will be removed from Jane's notification feed + delete_notification_activity: true, + }); + + // Eric removes his comment + await serverClient.feeds.deleteComment({ + id: commentId, + // When true the corresponding notification activity will be removed from Jane's notification feed + delete_notification_activity: true, + }); + + // Eric removes his activity reaction + await serverClient.feeds.deleteActivityReaction({ + activity_id: janeActivity.id, + type: 'like', + // When true the corresponding notification activity will be removed from Jane's notification feed + delete_notification_activity: true, + user_id: ericId, + }); + + // Eric removes his comment reaction + await serverClient.feeds.deleteCommentReaction({ + id: saraComment.id, + type: 'like', + // When true the corresponding notification activity will be removed from Jane's notification feed + delete_notification_activity: true, + user_id: ericId, + }); + + // Eric deletes his activity with mentions + await serverClient.feeds.deleteActivity({ + id: activityId, + // When true, mention notifications for all mentioned users will be removed + delete_notification_activity: true, + }); + + // Eric deletes multiple activities with mentions (batch operation) + await serverClient.feeds.deleteActivities({ + ids: [activityId1, activityId2], + // When true, mention notifications for all mentioned users will be removed + delete_notification_activity: true, + user_id: ericId, + }); + // #endregion snippet + + await waitForNotificationTypes(client, janeId, []); + await waitForNotificationTypes(client, saraId, []); + }); + }); + + describe('reading and marking notifications', () => { + let readerId: string; + let notificationFeed: ReturnType; + + beforeAll(async () => { + readerId = await createUser('reader'); + const notifierId = await createUser('notifier'); + notificationFeed = await createFeed('notification', readerId); + // Two notifications of different types: two aggregated groups. + for (const type of ['like', 'comment']) { + await client.feeds.addActivity({ + type, + feeds: [notificationFeed.feed], + text: `A ${type}`, + user_id: notifierId, + }); + } + await retry(async () => { + const response = await notificationFeed.getOrCreate({ + user_id: readerId, + }); + expect(response.aggregated_activities).toHaveLength(2); + expect(response.notification_status?.unread).toBe(2); + }); + }); + + const readGroups = async () => + (await notificationFeed.getOrCreate({ user_id: readerId })) + .aggregated_activities; + + it('reads notification activities', async () => { + const viewerId = readerId; + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Reading notification activities" tab="Node.js" index=1 + // COPY: readerId="jane", viewerId="" + const notificationFeed = client.feeds.feed('notification', readerId); + + // Read notifications + const notifications = ( + await notificationFeed.getOrCreate({ + limit: 20, + user_id: viewerId, + }) + ).aggregated_activities; + // #endregion snippet + + expect(notifications.map((g) => g.activities[0].type).sort()).toEqual([ + 'comment', + 'like', + ]); + for (const group of notifications) { + expect(group.activity_count).toBe(1); + expect(group.user_count).toBe(1); + } + }); + + it('reads server-side is_seen / is_read', async () => { + const [group] = await readGroups(); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Server-side `is_seen` / `is_read`" tab="Node.js" index=1 + const isRead = group.is_read; + const isSeen = group.is_seen; + // #endregion snippet + + expect(isRead).toBe(false); + expect(isSeen).toBe(false); + }); + + it('reads unread / unseen counts', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Unread/unseen counts" tab="Node.js" index=1 + // COPY: readerId="john" + const response = await notificationFeed.getOrCreate({ + user_id: readerId, + }); + const status = response.notification_status; + + const unread = status?.unread ?? 0; + const unseen = status?.unseen ?? 0; + // #endregion snippet + + expect(unread).toBe(2); + expect(unseen).toBe(2); + }); + + it('computes read / seen status client-side (legacy)', async () => { + const response = await notificationFeed.getOrCreate({ + user_id: readerId, + }); + const [group] = response.aggregated_activities; + const lastReadAt = response.notification_status?.last_read_at; + const lastSeenAt = response.notification_status?.last_seen_at; + const readActivities = + response.notification_status?.read_activities ?? []; + const seenActivities = + response.notification_status?.seen_activities ?? []; + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Legacy: Client-side computation" tab="Node.js" index=1 + // Deprecated — use group.is_read / group.is_seen instead + const isRead = + (lastReadAt && group.updated_at.getTime() < lastReadAt.getTime()) || + readActivities.includes(group.group); + const isSeen = + (lastSeenAt && group.updated_at.getTime() < lastSeenAt.getTime()) || + seenActivities.includes(group.group); + // #endregion snippet + + // Nothing was read or seen yet: same as the server-side flags. + expect(Boolean(isRead)).toBe(group.is_read); + expect(Boolean(isSeen)).toBe(group.is_seen); + }); + + it('marks notifications as seen', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Marking notifications as seen" tab="Node.js" index=1 + // COPY: readerId="" + await notificationFeed.markActivity({ + // Mark all notifications as seen... + mark_all_seen: true, + // ...or only selected ones + mark_seen: [/* group names to mark as seen */], + user_id: readerId, + }); + // #endregion snippet + + await retry(async () => { + const response = await notificationFeed.getOrCreate({ + user_id: readerId, + }); + expect(response.notification_status).toMatchObject({ + unseen: 0, + unread: 2, + }); + expect( + response.aggregated_activities.map((g) => [g.is_seen, g.is_read]), + ).toEqual([ + [true, false], + [true, false], + ]); + }); + }); + + it('marks notifications as read', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Marking notifications as read" tab="Node.js" index=1 + // COPY: readerId="" + await notificationFeed.markActivity({ + // Mark all notifications as read... + mark_all_read: true, + // ...or only selected ones + mark_read: [/* group names to mark as read */], + user_id: readerId, + }); + // #endregion snippet + + await retry(async () => { + const response = await notificationFeed.getOrCreate({ + user_id: readerId, + }); + expect(response.notification_status).toMatchObject({ + unseen: 0, + unread: 0, + }); + expect(response.aggregated_activities.map((g) => g.is_read)).toEqual([ + true, + true, + ]); + }); + }); + }); + + it('paginates a feed', async () => { + const jackId = await createUser('jack'); + const viewerId = jackId; + await createFeed('user', jackId); + // One more activity than a page: the docs' limit is COPY-mapped to 2. + const pageSize = 2; + for (const text of ['first', 'second', 'third']) { + await client.feeds.addActivity({ + type: 'post', + feeds: [`user:${jackId}`], + text, + user_id: jackId, + }); + } + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Pagination" tab="Node.js" index=1 + // COPY: jackId="jack", viewerId="user_id", pageSize=10 + const feed = client.feeds.feed('user', jackId); + const firstPage = await feed.getOrCreate({ + limit: pageSize, + user_id: viewerId, + }); + + const nextPage = await feed.getOrCreate({ + next: firstPage.next, + limit: pageSize, + user_id: viewerId, + }); + // #endregion snippet + + expect(firstPage.activities.map((a) => a.text)).toEqual([ + 'third', + 'second', + ]); + expect(typeof firstPage.next).toBe('string'); + expect(nextPage.activities.map((a) => a.text)).toEqual(['first']); + }); + + describe('flat notification feeds', () => { + // The built-in notification group is app-wide: the fences run on a + // uniqueId group created with its settings. + let flatGroupId: string; + + beforeAll(async () => { + const copy = await copyFeedGroup(client, cleanup, 'notification'); + expect(copy.builtIn.aggregation?.format).toBeTruthy(); + flatGroupId = copy.id; + }); + + beforeEach(() => + retryOnFeedGroupMissing(client, [ + 'updateFeedGroup', + 'getFeedGroup', + 'getOrCreateFeed', + 'markActivity', + ]), + ); + + it('turns off aggregation', async () => { + const before = await client.feeds.getFeedGroup({ id: flatGroupId }); + expect(before.feed_group.aggregation?.format).toBeTruthy(); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Turning off aggregation" tab="Node.js" index=1 + // COPY: flatGroupId="notification" + await client.feeds.updateFeedGroup({ + id: flatGroupId, + notification: { + track_read: true, + track_seen: true, + }, + push_notification: { + enable_push: true, + push_types: [/* list notification types here */], + }, + }); + // #endregion snippet + + const update = vi.spyOn(client.feeds, 'updateFeedGroup'); + const { feed_group } = await (update.mock.results[0] + .value as Promise); + expect(feed_group.aggregation).toBeUndefined(); + expect(feed_group.notification).toMatchObject({ + track_read: true, + track_seen: true, + }); + }); + + it('reads, paginates and marks flat notifications', async () => { + const userId = await createUser('flat-reader'); + const notifierId = await createUser('flat-notifier'); + await createFeed(flatGroupId, userId); + await waitForFeedGroup(flatGroupId, userId); + for (const text of ['first', 'second', 'third']) { + await addActivityOnce(client, { + type: 'like', + feeds: [`${flatGroupId}:${userId}`], + text, + user_id: notifierId, + }); + } + // The aggregation change reaches API nodes over a few seconds: wait + // until several reads in a row see a flat, paginated feed. + await retry(async () => { + for (let i = 0; i < 5; i++) { + const response = await client.feeds + .feed(flatGroupId, userId) + .getOrCreate({ limit: 2, user_id: userId }); + // Flat: three unread activities, no groups. + expect(response.notification_status?.unread).toBe(3); + expect(response.aggregated_activities).toEqual([]); + expect(typeof response.next).toBe('string'); + } + }); + // One more notification than a page: the docs' limit is COPY-mapped to 2. + const pageSize = 2; + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Reading and paginating flat notifications" tab="Node.js" index=1 + // COPY: flatGroupId="notification", pageSize=20 + const notificationFeed = client.feeds.feed(flatGroupId, userId); + + const first = await notificationFeed.getOrCreate({ + limit: pageSize, + user_id: userId, + }); + const page1 = first.activities; + + const second = await notificationFeed.getOrCreate({ + limit: pageSize, + next: first.next, + user_id: userId, + }); + // #endregion snippet + + expect(page1.map((a) => a.text)).toEqual(['third', 'second']); + expect(second.activities.map((a) => a.text)).toEqual(['first']); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Per-activity `is_seen` and `is_read`" tab="Node.js" index=1 + const activity = first.activities[0]; + const isRead = activity.is_read; + const isSeen = activity.is_seen; + // #endregion snippet + + expect(isRead).toBe(false); + expect(isSeen).toBe(false); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Marking flat notifications as seen or read" tab="Node.js" index=1 + await notificationFeed.markActivity({ + mark_seen: [activity.id], + user_id: userId, + }); + // #endregion snippet + + await retry(async () => { + const response = await client.feeds + .feed(flatGroupId, userId) + .getOrCreate({ user_id: userId }); + expect(response.notification_status).toMatchObject({ + unseen: 2, + unread: 3, + }); + expect( + response.activities.map((a) => [a.text, a.is_seen, a.is_read]), + ).toEqual([ + ['third', true, false], + ['second', false, false], + ['first', false, false], + ]); + }); + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/07-activity_processors.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/07-activity_processors.test.ts new file mode 100644 index 0000000..bd1debe --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/07-activity_processors.test.ts @@ -0,0 +1,259 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { UpdateFeedGroupResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + addActivityOnce, + isFeedGroupMissing, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/03-feed_groups/07-activity_processors.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +// Feed groups are app-wide: every group a fence creates gets a uniqueId id. +const newFeedGroupId = (name: string) => { + const id = uniqueId(name); + cleanup.feedGroups.push(id); + return id; +}; + +const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; +}; + +/** Activity processors run asynchronously: reads the activity until it has topics. */ +const waitForInterestTags = (id: string, timeout = 75000) => + retry( + async () => { + const { activity } = await client.feeds.getActivity({ id }); + if (!activity.interest_tags?.length) + throw new Error('no interest_tags yet'); + return activity.interest_tags; + }, + { timeout }, + ); + +/** + * A new feed group (and its processors) takes a few seconds to reach every API + * node: an activity stored by a node that doesn't know the group yet answers + * "not found" or never gets topics. Waits for several reads in a row. + */ +const waitForFeedGroup = (id: string) => + retry(async () => { + for (let i = 0; i < 5; i++) await client.feeds.getFeedGroup({ id }); + }); + +/** + * Setup only: posts an activity and waits for its topics, posting a fresh one + * if a node with a stale group config stored it without running the processors. + */ +const postAndWaitForTags = (fid: string, userId: string, text: string) => + retry( + async () => { + const id = await addActivityOnce(client, { + feeds: [fid], + type: 'post', + text, + user_id: userId, + }); + return waitForInterestTags(id, 25000); + }, + { timeout: 80000 }, + ); + +/** + * For a region that posts into a new group: `addActivity` gets an explicit id, so + * a "not found" answer for an activity that was stored anyway isn't retried + * into a duplicate (see `addActivityOnce`). + */ +const addActivityRetryingOnce = () => { + const addActivity = client.feeds.addActivity.bind(client.feeds); + return vi + .spyOn(client.feeds, 'addActivity') + .mockImplementation(async (request) => { + const id = request.id ?? uniqueId('activity'); + return retry( + async () => { + try { + return await addActivity({ ...request, id }); + } catch (error) { + const stored = await client.feeds + .getActivity({ id }) + .catch(() => undefined); + if (!stored) throw error; + const { duration, activity, metadata } = stored; + return { duration, activity, metadata }; + } + }, + { retryIf: isFeedGroupMissing }, + ); + }); +}; + +describe(DOCS, () => { + beforeEach(() => retryOnFeedGroupMissing(client, ['getOrCreateFeed'])); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('sets up activity processors', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/07-activity_processors.md" heading="Setting up activity processors" tab="Node.js" index=1 + // COPY: myId="myid" + const response = await serverClient.feeds.createFeedGroup({ + id: myId, + activity_processors: [ + { type: 'text_interest_tags' }, + { type: 'image_interest_tags' }, + ], + }); + // #endregion snippet + + expect(response.feed_group.id).toBe(myId); + expect(response.feed_group.activity_processors?.map((p) => p.type)).toEqual( + ['text_interest_tags', 'image_interest_tags'], + ); + }); + + it('updates a feed group', async () => { + // The docs talk about built-in groups, but those are app-wide: the fence + // runs on a uniqueId group. + const feedGroupId = newFeedGroupId('to-update'); + await client.feeds.createFeedGroup({ + id: feedGroupId, + activity_processors: [{ type: 'text_interest_tags' }], + }); + const updateFeedGroup = client.feeds.updateFeedGroup.bind(client.feeds); + const update = vi.spyOn(client.feeds, 'updateFeedGroup'); + update.mockImplementation((request) => + retry(() => updateFeedGroup(request), { retryIf: isFeedGroupMissing }), + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/07-activity_processors.md" heading="Setting up activity processors" tab="Node.js" index=2 + // COPY: feedGroupId="" + await client.feeds.updateFeedGroup({ + id: feedGroupId, + // Fields to update + }); + // #endregion snippet + + const { feed_group } = await (update.mock.results[0] + .value as Promise); + expect(feed_group.id).toBe(feedGroupId); + // Updates replace every field: the omitted processors are cleared. + expect(feed_group.activity_processors ?? []).toEqual([]); + }); + + it('sets extraction thresholds', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/07-activity_processors.md" heading="Extraction thresholds" tab="Node.js" index=1 + // COPY: myId="myid" + const response = await serverClient.feeds.createFeedGroup({ + id: myId, + activity_processors: [ + { type: 'text_interest_tags', min_text_length: 40, min_word_count: 5 }, + ], + }); + // #endregion snippet + + expect(response.feed_group.activity_processors).toMatchObject([ + { type: 'text_interest_tags', min_text_length: 40, min_word_count: 5 }, + ]); + }); + + it('restricts topics to an allowed vocabulary', async () => { + const myId = newFeedGroupId('myid'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/07-activity_processors.md" heading="Matching without send_allowed_tags_to_ai" tab="Node.js" index=1 + // COPY: myId="myid" + const response = await serverClient.feeds.createFeedGroup({ + id: myId, + activity_processors: [{ type: 'text_interest_tags' }], + activity_processing: { + allowed_tags: ['cycling', 'markets', 'cooking'], + send_allowed_tags_to_ai: true, + }, + }); + // #endregion snippet + + // Tags are stored sorted. + expect(response.feed_group.activity_processing).toMatchObject({ + allowed_tags: ['cooking', 'cycling', 'markets'], + send_allowed_tags_to_ai: true, + }); + // The processor only writes tags from the list. + await waitForFeedGroup(myId); + const userId = await createUser('cyclist'); + cleanup.feeds.push(`${myId}:${userId}`); + await client.feeds.feed(myId, userId).getOrCreate({ user_id: userId }); + const tags = await postAndWaitForTags( + `${myId}:${userId}`, + userId, + 'My new road bike made the 80 km ride over the mountain pass easy', + ); + expect(tags).toEqual(['cycling']); + }, 120000); + + it('reads topics', async () => { + const groupId = newFeedGroupId('topics'); + await client.feeds.createFeedGroup({ + id: groupId, + activity_processors: [{ type: 'text_interest_tags' }], + }); + const userId = await createUser('author'); + const fid = `${groupId}:${userId}`; + cleanup.feeds.push(fid); + await waitForFeedGroup(groupId); + await client.feeds.feed(groupId, userId).getOrCreate({ user_id: userId }); + await postAndWaitForTags( + fid, + userId, + 'my favourite pasta recipe for busy weeknights', + ); + addActivityRetryingOnce(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/07-activity_processors.md" heading="Reading topics" tab="Node.js" index=1 + // COPY: fid="user:1", userId="" + const response = await client.feeds.addActivity({ + feeds: [fid], + type: 'post', + text: 'check out my 10 fitness tips for reducing lower back pain', + user_id: userId, + }); + + // Wait for feeds.activity.updated event + console.log(response.activity.interest_tags); // ["fitness", "health", "tips"] + // #endregion snippet + + const added = response.activity; + expect(log).toHaveBeenCalledWith(added.interest_tags); + // The processor runs asynchronously: the topics show up later. Now and + // then an activity never gets topics (seen 2026-10-16, about 1 run in 4): + // then the same text posted again must get them. + const tags = await waitForInterestTags(added.id, 30000).catch(() => + postAndWaitForTags(fid, userId, added.text ?? ''), + ); + expect(tags.length).toBeGreaterThan(0); + }, 200000); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/08-for_you_feed.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/08-for_you_feed.test.ts new file mode 100644 index 0000000..e40b4e2 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/08-for_you_feed.test.ts @@ -0,0 +1,187 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + copyFeedGroup, + isFeedGroupMissing, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/03-feed_groups/08-for_you_feed.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +// Feed groups are app-wide: every group a test creates gets a uniqueId id. +const newFeedGroupId = (name: string) => { + const id = uniqueId(name); + cleanup.feedGroups.push(id); + return id; +}; + +const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; +}; + +/** Creates a feed (registered for cleanup) and returns its fid. */ +const createFeed = async (group: string, id: string, userId: string) => { + cleanup.feeds.push(`${group}:${id}`); + await retry( + () => client.feeds.feed(group, id).getOrCreate({ user_id: userId }), + { + retryIf: isFeedGroupMissing, + }, + ); + return `${group}:${id}`; +}; + +// "jack" posts to his user feed, to "user:1" and to a "stock:apple" feed. +let jackId: string; +let oneFid: string; +let appleFid: string; + +describe(DOCS, () => { + beforeEach(() => + retryOnFeedGroupMissing(client, ['updateFeedGroup', 'getOrCreateFeed']), + ); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + beforeAll(async () => { + jackId = await createUser('jack'); + const oneId = await createUser('one'); + await createFeed('user', jackId, jackId); + oneFid = await createFeed('user', oneId, oneId); + const stockGroupId = newFeedGroupId('stock'); + await client.feeds.createFeedGroup({ id: stockGroupId }); + appleFid = await createFeed(stockGroupId, uniqueId('apple'), jackId); + }); + + it('creates an interest ranked feed group', async () => { + // The built-in timeline group is app-wide: the fence runs on a uniqueId + // group created with its settings. + const { id: timelineId } = await copyFeedGroup(client, cleanup, 'timeline'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/08-for_you_feed.md" heading="Step 1 - Create a feed group" tab="Node.js" index=1 + // COPY: timelineId="timeline" + const response = await serverClient.feeds.updateFeedGroup({ + id: timelineId, + activity_selectors: [{ type: 'following' }, { type: 'popular' }], + ranking: { + type: 'interest', + score: + 'interest_score * (popularity > 0 ? popularity : 1) * decay_exp(time)', + }, + activity_processors: [{ type: 'text_interest_tags' }], + }); + // #endregion snippet + + const { feed_group } = response; + expect(feed_group.id).toBe(timelineId); + expect(feed_group.ranking).toMatchObject({ + type: 'interest', + score: + 'interest_score * (popularity > 0 ? popularity : 1) * decay_exp(time)', + }); + expect(feed_group.activity_selectors?.map((s) => s.type)).toEqual([ + 'following', + 'popular', + ]); + expect(feed_group.activity_processors?.map((p) => p.type)).toEqual([ + 'text_interest_tags', + ]); + }); + + it('adds activities with interest tags', async () => { + // The built-in user group runs no activity processors on the test app + // (2026-10-16), so the first activity gets no topics, despite the comment. + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/08-for_you_feed.md" heading="Step 2 - Activities with interest tags" tab="Node.js" index=1 + // COPY: jackId="jack", oneFid="user:1", appleFid="stock:apple" + const feed = client.feeds.feed('user', jackId); + + // since we've enabled the "text_topics" activity processor the "activity.interest_tags" will be automatically set + const response = await client.feeds.addActivity({ + feeds: [feed.feed], + type: 'post', + text: 'apple stock will go up', + user_id: jackId, + }); + + // you can also set this field manually + const responseWithTags = await client.feeds.addActivity({ + feeds: [oneFid, appleFid], + type: 'post', + text: 'apple stock will go up', + interest_tags: ['apple', 'stock'], + user_id: jackId, + }); + // #endregion snippet + + expect(response.activity.feeds).toEqual([`user:${jackId}`]); + expect(responseWithTags.activity.interest_tags).toEqual(['apple', 'stock']); + expect(responseWithTags.activity.feeds).toEqual([oneFid, appleFid]); + }); + + it('reads with interest weights', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/08-for_you_feed.md" heading="Step 3 - Reading the interest based ranked feed" tab="Node.js" index=1 + // COPY: jackId="jack" + const interestWeights = { + go: 1.0, // User loves Go + python: 0.5, // User is ok with Python + ruby: -1.0, // User dislikes Ruby + }; + const feed = client.feeds.feed('user', jackId); + const response = await feed.getOrCreate({ + limit: 10, + interest_weights: interestWeights, // Merged with the user's stored interests for this request + user_id: jackId, + }); + // #endregion snippet + + expect(response.feed.feed).toBe(`user:${jackId}`); + expect(response.activities.map((a) => a.text)).toEqual([ + 'apple stock will go up', + ]); + }); + + it('seeds interests at onboarding', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/08-for_you_feed.md" heading="Seeding interests at onboarding" tab="Node.js" index=1 + // COPY: jackId="jack" + // Called from your backend once the user completes the onboarding questionnaire + await client.feeds.upsertUserInterests({ + user_id: jackId, + interests: [ + { tag: 'go' }, // weight defaults to 1.0 + { tag: 'python', weight: 0.5 }, + { tag: 'ruby', weight: -1.0 }, // a dislike is applied as a penalty in ranking + ], + }); + // #endregion snippet + + const { interests } = await client.feeds.getUserInterests({ + user_id: jackId, + }); + expect(interests).toMatchObject([ + { tag: 'go', weight: 1, source: 'manual' }, + { tag: 'python', weight: 0.5, source: 'manual' }, + { tag: 'ruby', weight: -1, source: 'manual' }, + ]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/09-stories.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/09-stories.test.ts new file mode 100644 index 0000000..8d88078 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/09-stories.test.ts @@ -0,0 +1,321 @@ +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 { retry } from '../../../../helpers/wait'; + +const DOCS = 'activity-feeds/v3-latest/_default/03-feed_groups/09-stories.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +// Feed groups are app-wide: every group a fence creates gets a uniqueId id. +const newFeedGroupId = (name: string) => { + const id = uniqueId(name); + cleanup.feedGroups.push(id); + return id; +}; + +const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; +}; + +/** Creates (and registers) a feed of a built-in group, owned by `userId`. */ +const createFeed = async (group: string, userId: string) => { + cleanup.feeds.push(`${group}:${userId}`); + await client.feeds.feed(group, userId).getOrCreate({ user_id: userId }); + return `${group}:${userId}`; +}; + +const inOneHour = () => new Date(Date.now() + 60 * 60 * 1000).toISOString(); + +/** Posts a story (expiring in an hour) to the user's built-in `story` feed. */ +const postStory = async (userId: string, text: string) => { + const response = await client.feeds.addActivity({ + type: 'post', + feeds: [`story:${userId}`], + text, + expires_at: inOneHour(), + user_id: userId, + }); + return response.activity.id; +}; + +describe(DOCS, () => { + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates story feed groups', async () => { + const myStory = newFeedGroupId('my-story'); + const myStories = newFeedGroupId('my-stories'); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/09-stories.md" heading="Creating story groups" tab="Node.js" index=1 + // COPY: myStory=`my-story`, myStories=`my-stories` + await serverClient.feeds.createFeedGroup({ + id: myStory, + stories: { + skip_watched: false, + track_watched: true, + }, + }); + + await serverClient.feeds.createFeedGroup({ + id: myStories, + stories: { + skip_watched: false, + track_watched: true, + }, + aggregation: { + // Typically stories are aggregated by user id + format: '{{ user_id }}', + }, + activity_selectors: [{ type: 'following' }], + }); + // #endregion snippet + + const story = await retry(() => client.feeds.getFeedGroup({ id: myStory })); + expect(story.feed_group.stories).toMatchObject({ track_watched: true }); + const stories = await retry(() => + client.feeds.getFeedGroup({ id: myStories }), + ); + expect(stories.feed_group.stories).toMatchObject({ track_watched: true }); + expect(stories.feed_group.aggregation?.format).toBe('{{ user_id }}'); + expect(stories.feed_group.activity_selectors?.[0].type).toBe('following'); + }); + + it('posts a story', async () => { + const johnId = await createUser('john'); + cleanup.feeds.push(`story:${johnId}`); + const johnStoryFid = `story:${johnId}`; + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/09-stories.md" heading="Posting stories" tab="Node.js" index=1 + // COPY: johnId="john", johnStoryFid="story:john" + const johnStoryFeed = client.feeds.feed('story', johnId); + await johnStoryFeed.getOrCreate({ + user_id: johnId, + }); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + await client.feeds.addActivity({ + type: 'post', + feeds: [johnStoryFid], + attachments: [ + { + image_url: 'https://example.com/image.jpg', + custom: {}, + }, + ], + expires_at: tomorrow.toISOString(), + user_id: johnId, + }); + // #endregion snippet + + const feed = await johnStoryFeed.getOrCreate({ user_id: johnId }); + expect(feed.activities).toHaveLength(1); + const [story] = feed.activities; + expect(story.attachments[0].image_url).toBe( + 'https://example.com/image.jpg', + ); + // The stored time can be 1 ms off the ISO string that was sent. + expect( + Math.abs((story.expires_at?.getTime() ?? 0) - tomorrow.getTime()), + ).toBeLessThanOrEqual(1); + }); + + it('follows a story feed', async () => { + const johnId = await createUser('john'); + const saraId = await createUser('sara'); + const johnStoryFid = await createFeed('story', johnId); + const saraStoriesFid = await createFeed('stories', saraId); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/09-stories.md" heading="Following story feeds" tab="Node.js" index=1 + // COPY: saraStoriesFid="stories:sara", johnStoryFid="story:john" + await client.feeds.follow({ source: saraStoriesFid, target: johnStoryFid }); + // #endregion snippet + + const follows = await client.feeds.queryFollows({ + filter: { source_feed: saraStoriesFid }, + }); + expect(follows.follows.map((f) => f.target_feed.feed)).toEqual([ + johnStoryFid, + ]); + }); + + it('reads the story timeline and marks a story as watched', async () => { + const johnId = await createUser('john'); + const saraId = await createUser('sara'); + const johnStoryFid = await createFeed('story', johnId); + const saraStoriesFid = await createFeed('stories', saraId); + await client.feeds.follow({ source: saraStoriesFid, target: johnStoryFid }); + const first = await postStory(johnId, 'first story'); + const second = await postStory(johnId, 'second story'); + // The timeline fills in the background after the posts. + await retry(async () => { + const timeline = await client.feeds + .feed('stories', saraId) + .getOrCreate({ user_id: saraId }); + expect( + timeline.aggregated_activities[0]?.activities.map((a) => a.id).sort(), + ).toEqual([first, second].sort()); + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/09-stories.md" heading="Reading story timeline and marking activities as watched" tab="Node.js" index=1 + // COPY: saraId="sara" + const saraStoryTimeline = client.feeds.feed('stories', saraId); + const response = await saraStoryTimeline.getOrCreate({ user_id: saraId }); + + // Since story timeline is aggregated by user id, we read aggregated activities + const johnStories = response.aggregated_activities[0]; + // True if we watched all active stories of a user + console.log(johnStories.is_watched); + + // Display all of John's active stories + johnStories.activities.forEach((activity) => { + // True if we watched a given story + console.log(activity.is_watched); + }); + + // Mark a story as read + await saraStoryTimeline.markActivity({ + mark_watched: [johnStories.activities[0].id], + user_id: saraId, + }); + // #endregion snippet + + log.mockRestore(); + expect(johnStories.is_watched).toBe(false); + expect(johnStories.activities.map((a) => a.is_watched)).toEqual([ + false, + false, + ]); + const watchedId = johnStories.activities[0].id; + await retry(async () => { + const after = await saraStoryTimeline.getOrCreate({ user_id: saraId }); + const [group] = after.aggregated_activities; + const watched = Object.fromEntries( + group.activities.map((a) => [a.id, a.is_watched]), + ); + expect(watched).toEqual({ + [watchedId]: true, + [watchedId === first ? second : first]: false, + }); + expect(group.is_watched).toBe(false); + }); + }); + + it('paginates a feed', async () => { + const jackId = await createUser('jack'); + const viewerId = await createUser('user_id'); + await createFeed('user', jackId); + await client.feeds.upsertActivities({ + activities: Array.from({ length: 12 }, (_, i) => ({ + type: 'post', + feeds: [`user:${jackId}`], + text: `post ${i}`, + user_id: jackId, + })), + }); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/09-stories.md" heading="Reading story timeline and marking activities as watched" tab="Node.js" index=2 + // COPY: jackId="jack", viewerId="user_id" + const feed = client.feeds.feed('user', jackId); + const firstPage = await feed.getOrCreate({ + limit: 10, + user_id: viewerId, + }); + + const nextPage = await feed.getOrCreate({ + next: firstPage.next, + limit: 10, + user_id: viewerId, + }); + // #endregion snippet + + expect(firstPage.activities).toHaveLength(10); + expect(firstPage.next).toBeTruthy(); + expect(nextPage.activities).toHaveLength(2); + const ids = [...firstPage.activities, ...nextPage.activities].map( + (a) => a.id, + ); + expect(new Set(ids).size).toBe(12); + }); + + it('reads a story feed and marks a story as watched', async () => { + const johnId = await createUser('john'); + const saraId = await createUser('sara'); + await createFeed('story', johnId); + const storyId = await postStory(johnId, 'a story'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/09-stories.md" heading="Reading story feed" tab="Node.js" index=1 + // COPY: johnId="john", saraId="sara" + const johnStoryFeed = client.feeds.feed('story', johnId); + + const response = await johnStoryFeed.getOrCreate({ + limit: 100, + user_id: johnId, + }); + + const johnStories = response.activities; + + johnStories.forEach((activity) => { + // True if we watched a given story + console.log(activity.is_watched); + }); + + // Mark a story as watched + await johnStoryFeed.markActivity({ + mark_watched: [johnStories[0].id], + user_id: saraId, + }); + // #endregion snippet + + log.mockRestore(); + expect(johnStories.map((a) => a.id)).toEqual([storyId]); + expect(johnStories[0].is_watched).toBe(false); + await retry(async () => { + const asSara = await johnStoryFeed.getOrCreate({ user_id: saraId }); + expect(asSara.activities[0].is_watched).toBe(true); + }); + }); + + it('queries expired stories', async () => { + const johnId = await createUser('john'); + const john = { id: johnId }; + await createFeed('story', johnId); + // The API accepts an `expires_at` in the past: that story is expired at once. + const expired = await client.feeds.addActivity({ + type: 'post', + feeds: [`story:${johnId}`], + text: 'expired story', + expires_at: new Date(Date.now() - 60 * 1000).toISOString(), + user_id: johnId, + }); + await postStory(johnId, 'active story'); + + const result = await retry(async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/09-stories.md" heading="Reading expired stories" tab="Node.js" index=1 + const now = new Date(); + const result = await client.feeds.queryActivities({ + filter: { + expires_at: { $lte: now.toISOString() }, + user_id: john.id, + }, + sort: [{ field: 'created_at', direction: -1 }], + }); + // #endregion snippet + + expect(result.activities.map((a) => a.id)).toEqual([expired.activity.id]); + return result; + }); + expect(result.activities[0].text).toBe('expired story'); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/10-user-interests.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/10-user-interests.test.ts new file mode 100644 index 0000000..e6fbca2 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/03-feed_groups/10-user-interests.test.ts @@ -0,0 +1,78 @@ +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 = + 'activity-feeds/v3-latest/_default/03-feed_groups/10-user-interests.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +// Interests are per user: they go away with the uniqueId user. +let aliceId: string; + +describe(DOCS, () => { + afterAll(() => cleanup.run()); + + beforeAll(async () => { + aliceId = uniqueId('alice'); + cleanup.users.push(aliceId); + await client.upsertUsers([{ id: aliceId }]); + }); + + it('sets interests', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/10-user-interests.md" heading="Setting interests" tab="Node.js" index=1 + // COPY: aliceId="alice" + const response = await client.feeds.upsertUserInterests({ + user_id: aliceId, + interests: [ + { tag: 'cycling', weight: 0.8 }, + { tag: 'jazz' }, // weight defaults to 1.0 + { tag: 'golf', weight: -1.0 }, // a dislike + ], + }); + // #endregion snippet + + // The full list, by descending weight. + expect(response.interests).toMatchObject([ + { tag: 'jazz', weight: 1, source: 'manual', count: 0 }, + { tag: 'cycling', weight: 0.8, source: 'manual', count: 0 }, + { tag: 'golf', weight: -1, source: 'manual', count: 0 }, + ]); + }); + + it('reads interests', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/10-user-interests.md" heading="Reading interests" tab="Node.js" index=1 + // COPY: aliceId="alice" + const response = await client.feeds.getUserInterests({ + user_id: aliceId, + limit: 20, + }); + + console.log(response.interests); + // #endregion snippet + + expect(response.interests.map((i) => i.tag)).toEqual([ + 'jazz', + 'cycling', + 'golf', + ]); + }); + + it('removes interests', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/10-user-interests.md" heading="Removing interests" tab="Node.js" index=1 + // COPY: aliceId="alice" + const response = await client.feeds.deleteUserInterests({ + user_id: aliceId, + tags: ['cycling', 'jazz'], + }); + // #endregion snippet + + expect(response.interests.map((i) => i.tag)).toEqual(['golf']); + const { interests } = await client.feeds.getUserInterests({ + user_id: aliceId, + }); + expect(interests.map((i) => i.tag)).toEqual(['golf']); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/01-feeds.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/01-feeds.test.ts new file mode 100644 index 0000000..0276510 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/01-feeds.test.ts @@ -0,0 +1,495 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + CreateFeedsBatchRequest, + GetOrCreateFeedResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + isFeedGroupMissing, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry, waitForTask } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +/** A `user` feed with a uniqueId id, registered for cleanup (not created). */ +const newFeedId = (name: string) => { + const id = uniqueId(name); + cleanup.feeds.push(`user:${id}`); + return id; +}; + +/** Creates a `user` feed owned by `ownerId`. */ +const createFeed = async (name: string, ownerId: string) => { + const id = newFeedId(name); + const feed = client.feeds.feed('user', id); + await feed.getOrCreate({ user_id: ownerId }); + return feed; +}; + +const texts = (response: GetOrCreateFeedResponse) => + response.activities.map((a) => a.text).sort(); + +describe(DOCS, () => { + let userId: string; + + beforeAll(async () => { + userId = uniqueId('owner'); + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates a feed', async () => { + const jackId = newFeedId('jack'); + const janeId = newFeedId('jane'); + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Creating a Feed" tab="Node.js" index=1 + // COPY: jackId="jack", janeId="jane", userId="" + // Feed with no extra fields, of feed group "user" + const feed = client.feeds.feed('user', jackId); + await feed.getOrCreate({ + // The owner of the feed + user_id: userId, + }); + + // More options + const feedWithOptions = client.feeds.feed('user', janeId); + await feedWithOptions.getOrCreate({ + data: { + description: 'My personal feed', + name: 'jane', + visibility: 'public', + filter_tags: ['tech', 'hiking', 'cooking'], + }, + // The owner of the feed + user_id: userId, + }); + // #endregion snippet + + const [first, second] = await Promise.all( + getOrCreate.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(first.created).toBe(true); + expect(first.feed.feed).toBe(`user:${jackId}`); + expect(first.feed.created_by.id).toBe(userId); + expect(second.created).toBe(true); + expect(second.feed).toMatchObject({ + feed: `user:${janeId}`, + description: 'My personal feed', + name: 'jane', + visibility: 'public', + filter_tags: ['tech', 'hiking', 'cooking'], + }); + }); + + it('reads a feed', async () => { + const johnId = newFeedId('john'); + await client.feeds.getOrCreateFeed({ + feed_group_id: 'user', + feed_id: johnId, + user_id: userId, + }); + await client.feeds.addActivity({ + feeds: [`user:${johnId}`], + type: 'post', + text: 'hello', + user_id: userId, + }); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Reading a Feed" tab="Node.js" index=1 + // COPY: johnId="john", userId="" + const feed = client.feeds.feed('user', johnId); + const response = await feed.getOrCreate({ + user_id: userId, + }); + + const feedData = response.feed; + const activities = response.activities; + const members = response.members; + // #endregion snippet + + expect(response.created).toBe(false); + expect(feedData.feed).toBe(`user:${johnId}`); + expect(activities.map((a) => a.text)).toEqual(['hello']); + expect(members).toEqual([]); + }); + + it('reads a feed with options and a view', async () => { + const jackId = newFeedId('jack'); + const viewId = uniqueId('myview'); + cleanup.add(() => + client.feeds.deleteFeedView({ id: viewId }).catch((error: unknown) => { + if (!String(error).includes('not found')) throw error; + }), + ); + await client.feeds.createFeedView({ + id: viewId, + activity_selectors: [{ type: 'current_feed' }], + }); + await retry(() => client.feeds.getFeedView({ id: viewId }), { + retryIf: isFeedGroupMissing, + }); + await client.feeds.getOrCreateFeed({ + feed_group_id: 'user', + feed_id: jackId, + user_id: userId, + }); + await client.feeds.upsertActivities({ + activities: [ + { tags: ['green'], text: 'green' }, + { tags: ['red'], text: 'red' }, + ].map(({ tags, text }) => ({ + feeds: [`user:${jackId}`], + type: 'post', + text, + filter_tags: tags, + user_id: userId, + })), + }); + // Other API nodes may not know the view yet. + retryOnFeedGroupMissing(client, ['getOrCreateFeed']); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Reading a Feed" tab="Node.js" index=2 + // COPY: jackId="jack", viewId="myview", userId="" + const feed = client.feeds.feed('user', jackId); + const response = await feed.getOrCreate({ + limit: 10, + filter: { + filter_tags: ['green'], // filter activities with filter tag green + }, + external_ranking: { + user_score: 0.8, // additional data used for ranking + }, + followers_pagination: { + limit: 10, + }, + following_pagination: { + limit: 10, + }, + member_pagination: { + limit: 10, + }, + view: viewId, // overwrite the default ranking or aggregation logic for this feed. good for split testing + user_id: userId, + }); + // #endregion snippet + + expect(texts(response)).toEqual(['green']); + expect(response.followers).toEqual([]); + expect(response.following).toEqual([]); + }); + + it('reads a feed with enrichment options', async () => { + const jackId = (await createFeed('jack', userId)).id; + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Example" tab="Node.js" index=1 + // COPY: jackId="jack", userId="" + const feed = client.feeds.feed('user', jackId); + const response = await feed.getOrCreate({ + enrichment_options: { + // Configure enrichment options here + }, + user_id: userId, + }); + // #endregion snippet + + expect(response.created).toBe(false); + expect(response.feed.feed).toBe(`user:${jackId}`); + }); + + it('paginates a feed', async () => { + const jackId = (await createFeed('jack', userId)).id; + // One more activity than the docs page size, so there is a next page. + await client.feeds.upsertActivities({ + activities: Array.from({ length: 11 }, (_, i) => ({ + feeds: [`user:${jackId}`], + type: 'post', + text: `activity ${i}`, + user_id: userId, + })), + }); + const ownerId = userId; + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Feed Pagination" tab="Node.js" index=1 + // COPY: jackId="jack", ownerId="user_id" + const feed = client.feeds.feed('user', jackId); + const firstPage = await feed.getOrCreate({ + limit: 10, + user_id: ownerId, + }); + + const nextPage = await feed.getOrCreate({ + next: firstPage.next, + limit: 10, + user_id: ownerId, + }); + // #endregion snippet + + expect(firstPage.activities).toHaveLength(10); + expect(typeof firstPage.next).toBe('string'); + expect(nextPage.activities).toHaveLength(1); + const ids = [...firstPage.activities, ...nextPage.activities].map( + (a) => a.id, + ); + expect(new Set(ids).size).toBe(11); + }); + + it('filters activities by filter tag', async () => { + const feedId = (await createFeed('feed', userId)).id; + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Examples" tab="Node.js" index=1 + // COPY: feedId="123", userId="" + const feed = client.feeds.feed('user', feedId); + // Add a few activities + await client.feeds.upsertActivities({ + activities: [ + { + feeds: [feed.feed], + type: 'post', + text: 'first', + filter_tags: ['green', 'blue'], + user_id: userId, + }, + { + feeds: [feed.feed], + type: 'post', + text: 'second', + filter_tags: ['yellow', 'blue'], + user_id: userId, + }, + { + feeds: [feed.feed], + type: 'post', + text: 'third', + filter_tags: ['orange'], + user_id: userId, + }, + ], + }); + + const response = await feed.getOrCreate({ + filter: { + filter_tags: ['blue'], + }, + user_id: userId, + }); + // #endregion snippet + + expect(texts(response)).toEqual(['first', 'second']); + }); + + describe('filters with $and', () => { + let feed: Awaited>; + + beforeAll(async () => { + feed = await createFeed('feed', userId); + await client.feeds.upsertActivities({ + activities: [ + { text: 'green', tags: ['green'] }, + { text: 'orange', tags: ['orange'] }, + { text: 'both', tags: ['green', 'orange'] }, + ].map(({ text, tags }) => ({ + feeds: [feed.feed], + type: 'post', + text, + filter_tags: tags, + user_id: userId, + })), + }); + }); + + it('reads the activities matching both tags', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Examples" tab="Node.js" index=2 + // COPY: userId="" + // Get all the activities where filter tags contain both "green" and "orange" + const response = await feed.getOrCreate({ + filter: { + $and: [{ filter_tags: ['green'] }, { filter_tags: ['orange'] }], + }, + user_id: userId, + }); + // #endregion snippet + + expect(texts(response)).toEqual(['both']); + }); + + it('builds the same filter', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Supported filters" tab="Node.js" index=1 + // Get all the activities where filter tags contain both "green" and "orange" + const filter = { + $and: [{ filter_tags: ['green'] }, { filter_tags: ['orange'] }], + }; + // #endregion snippet + + const response = await feed.getOrCreate({ filter, user_id: userId }); + expect(texts(response)).toEqual(['both']); + }); + }); + + it('updates a feed', async () => { + const joshId = uniqueId('josh'); + cleanup.users.push(joshId); + await client.upsertUsers([{ id: joshId }]); + const feed = await createFeed('feed', userId); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Updating a Feed" tab="Node.js" index=1 + // COPY: joshId="josh" + const response = await feed.update({ + created_by_id: joshId, + name: 'Updated feed name', + filter_tags: ['tech', 'hiking', 'cooking'], + custom: { + color: 'blue', + }, + }); + // #endregion snippet + + expect(response.feed).toMatchObject({ + name: 'Updated feed name', + filter_tags: ['tech', 'hiking', 'cooking'], + custom: { color: 'blue' }, + }); + expect(response.feed.created_by.id).toBe(joshId); + }); + + it("clears a feed's location", async () => { + const feed = await createFeed('feed', userId); + const located = await feed.update({ location: { lat: 52.37, lng: 4.9 } }); + expect(located.feed.location).toEqual({ lat: 52.37, lng: 4.9 }); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Clearing a feed's location" tab="Node.js" index=1 + const response = await feed.update({ + clear_location: true, + }); + // #endregion snippet + + expect(response.feed.location).toBeUndefined(); + const read = await feed.getOrCreate({ user_id: userId }); + expect(read.feed.location).toBeUndefined(); + }); + + it('deletes a feed', async () => { + const feed = await createFeed('feed', userId); + await client.feeds.addActivity({ + feeds: [feed.feed], + type: 'post', + text: 'deleted with the feed', + user_id: userId, + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Deleting a Feed" tab="Node.js" index=1 + // Soft delete a feed + const softDeleteResponse = await feed.delete({ hard_delete: false }); + + // Hard delete a feed + const response = await feed.delete({ hard_delete: true }); + + // Check task progress for server side requests (you need to poll getTask) + const taskResponse = await client.getTask({ id: response.task_id }); + console.log(taskResponse.status === 'completed'); + // #endregion snippet + + expect(typeof softDeleteResponse.task_id).toBe('string'); + expect(taskResponse.task_id).toBe(response.task_id); + expect(log.mock.calls[0]).toEqual([taskResponse.status === 'completed']); + // Once the hard delete task is done, the feed and its activity are gone: + // the same id creates a new, empty feed. + await waitForTask(client, response.task_id); + const recreated = await feed.getOrCreate({ user_id: userId }); + expect(recreated.created).toBe(true); + expect(recreated.activities).toEqual([]); + }); + + it('creates and deletes feeds in a batch', async () => { + // The docs' fixed ids (`feed_0` ... `feed_99`) would be shared across the + // app: the spy prefixes each `feed_id` with a uniqueId, the rest of the + // request goes through as written. + const prefix = uniqueId('batch'); + const fids = Array.from( + { length: 100 }, + (_, i) => `user:${prefix}-feed_${i}`, + ); + cleanup.add(() => + client.feeds + .deleteFeedsBatch({ feeds: fids, hard_delete: true }) + .catch((error: unknown) => { + // The region deletes them first. + if (!String(error).includes("can't be found")) throw error; + }), + ); + const createBatch = client.feeds.createFeedsBatch.bind(client.feeds); + const create = vi + .spyOn(client.feeds, 'createFeedsBatch') + .mockImplementation((request: CreateFeedsBatchRequest) => + createBatch({ + ...request, + feeds: request.feeds.map((f) => ({ + ...f, + feed_id: `${prefix}-${f.feed_id}`, + })), + }), + ); + const deleteBatch = vi.spyOn(client.feeds, 'deleteFeedsBatch'); + const ownerId = userId; + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/01-feeds.md" heading="Batch create and delete" tab="Node.js" index=1 + // COPY: ownerId="" + const response = await serverClient.feeds.createFeedsBatch({ + feeds: new Array(100).fill(0).map((_, index) => ({ + feed_id: `feed_${index}`, + feed_group_id: 'user', + name: `Feed ${index}`, + visibility: 'public', + created_by_id: ownerId, + })), + }); + await serverClient.feeds.deleteFeedsBatch({ + feeds: response.feeds.map((feed) => feed.feed), + hard_delete: true, + }); + // #endregion snippet + + expect(create).toHaveBeenCalledTimes(1); + expect(response.feeds.map((f) => f.feed).sort()).toEqual([...fids].sort()); + expect(response.feeds[0]).toMatchObject({ + visibility: 'public', + created_by: { id: userId }, + }); + expect(response.feeds.find((f) => f.feed === fids[7])?.name).toBe('Feed 7'); + const { task_id } = await (deleteBatch.mock.results[0].value as ReturnType< + typeof client.feeds.deleteFeedsBatch + >); + await waitForTask(client, task_id); + const { feeds } = await retry(async () => { + const result = await client.feeds.queryFeeds({ + filter: { id: { $in: fids.map((fid) => fid.slice('user:'.length)) } }, + limit: 100, + }); + if (result.feeds.length) throw new Error('feeds still listed'); + return result; + }); + expect(feeds).toEqual([]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/02-search-feeds.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/02-search-feeds.test.ts new file mode 100644 index 0000000..f648642 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/02-search-feeds.test.ts @@ -0,0 +1,289 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { QueryFeedsResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { runId, uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/04-feeds-and-follows/02-search-feeds.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +/** A `user` feed with a uniqueId id, registered for cleanup (not created). */ +const newFeedId = (name: string) => { + const id = uniqueId(name); + cleanup.feeds.push(`user:${id}`); + return id; +}; + +const newUser = async (name: string, displayName?: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id, name: displayName }]); + return id; +}; + +const fids = (response: QueryFeedsResponse) => + response.feeds.map((f) => f.feed); + +/** Results of every `queryFeeds` call made since the spy was installed. */ +const queryResults = (spy: { mock: { results: Array<{ value: unknown }> } }) => + Promise.all( + spy.mock.results.map((r) => r.value as Promise), + ); + +describe(DOCS, () => { + let ownerId: string; + + beforeAll(async () => { + ownerId = await newUser('owner'); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('queries my feeds with pagination', async () => { + const johnId = await newUser('john'); + const created = Array.from({ length: 12 }, () => newFeedId('johnfeed')); + await client.feeds.createFeedsBatch({ + feeds: created.map((feed_id) => ({ + feed_group_id: 'user', + feed_id, + created_by_id: johnId, + })), + }); + const expected = created.map((id) => `user:${id}`).sort(); + // Wait until every feed is queryable, so the region sees them all. + await retry(async () => { + const all = await client.feeds.queryFeeds({ + filter: { created_by_id: johnId }, + limit: 25, + }); + expect(fids(all).sort()).toEqual(expected); + }); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/02-search-feeds.md" heading="Querying My Feeds" tab="Node.js" index=1 + // COPY: johnId="john" + const firstPage = await client.feeds.queryFeeds({ + filter: { + created_by_id: johnId, + }, + limit: 10, + sort: [{ field: 'created_at', direction: -1 }], + }); + + const secondPage = await client.feeds.queryFeeds({ + filter: { + created_by_id: johnId, + }, + limit: 10, + sort: [{ field: 'created_at', direction: -1 }], + next: firstPage.next, + }); + // #endregion snippet + + expect(firstPage.feeds).toHaveLength(10); + expect(firstPage.next).toBeTruthy(); + expect(secondPage.feeds).toHaveLength(2); + expect([...fids(firstPage), ...fids(secondPage)].sort()).toEqual(expected); + const times = [...firstPage.feeds, ...secondPage.feeds].map((f) => + f.created_at.getTime(), + ); + expect(times).toEqual([...times].sort((a, b) => b - a)); + }); + + it('queries feeds where I am a member', async () => { + const johnId = await newUser('john'); + const memberFeedId = newFeedId('memberfeed'); + const otherFeedId = newFeedId('otherfeed'); + await client.feeds.feed('user', memberFeedId).getOrCreate({ + user_id: ownerId, + data: { members: [{ user_id: johnId }] }, + }); + await client.feeds.feed('user', otherFeedId).getOrCreate({ + user_id: ownerId, + }); + await retry(async () => { + const r = await client.feeds.queryFeeds({ + filter: { members: { $in: [johnId] } }, + }); + expect(fids(r)).toEqual([`user:${memberFeedId}`]); + }); + const query = vi.spyOn(client.feeds, 'queryFeeds'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/02-search-feeds.md" heading="Querying Feeds Where I Am a Member" tab="Node.js" index=1 + // COPY: johnId="john" + await client.feeds.queryFeeds({ + filter: { + members: { $in: [johnId] }, + }, + }); + // #endregion snippet + + const [response] = await queryResults(query); + expect(fids(response)).toEqual([`user:${memberFeedId}`]); + }); + + it('queries feeds by name or description', async () => { + const token = `name${runId}`; + const matchingId = newFeedId('sports'); + const privateId = newFeedId('sportsprivate'); + await client.feeds.feed('user', matchingId).getOrCreate({ + user_id: ownerId, + data: { + visibility: 'public', + name: `Sports ${token}`, + description: `tech news ${token}`, + }, + }); + // Same name and description, but not public: must not be returned. + await client.feeds.feed('user', privateId).getOrCreate({ + user_id: ownerId, + data: { + visibility: 'private', + name: `Sports ${token}`, + description: `tech news ${token}`, + }, + }); + await retry(async () => { + const r = await client.feeds.queryFeeds({ + filter: { name: { $q: token } }, + }); + expect(fids(r)).toHaveLength(2); + }); + const query = vi.spyOn(client.feeds, 'queryFeeds'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/02-search-feeds.md" heading="Querying feeds by name or description" tab="Node.js" index=1 + await client.feeds.queryFeeds({ + filter: { + visibility: { $eq: 'public' }, + name: { $q: 'Sports' }, + }, + }); + + // search public feeds by description + await client.feeds.queryFeeds({ + filter: { + visibility: 'public', + description: { $q: 'tech' }, + }, + }); + // #endregion snippet + + // The filters match the whole app: check the test's feeds are in the results. + const [byName, byDescription] = await queryResults(query); + for (const response of [byName, byDescription]) { + expect(fids(response)).toContain(`user:${matchingId}`); + expect(fids(response)).not.toContain(`user:${privateId}`); + expect(response.feeds.every((f) => f.visibility === 'public')).toBe(true); + } + expect(byName.feeds.every((f) => /sports/i.test(f.name))).toBe(true); + expect(byDescription.feeds.every((f) => /tech/i.test(f.description))).toBe( + true, + ); + }); + + it('queries feeds by creator name', async () => { + const thompsonId = await newUser('thompson', `Rick Thompson ${runId}`); + const publicId = newFeedId('thompsonpublic'); + const privateId = newFeedId('thompsonprivate'); + await client.feeds.feed('user', publicId).getOrCreate({ + user_id: thompsonId, + data: { visibility: 'public' }, + }); + await client.feeds.feed('user', privateId).getOrCreate({ + user_id: thompsonId, + data: { visibility: 'private' }, + }); + await retry(async () => { + const r = await client.feeds.queryFeeds({ + filter: { created_by_id: thompsonId }, + }); + expect(fids(r)).toHaveLength(2); + }); + const query = vi.spyOn(client.feeds, 'queryFeeds'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/02-search-feeds.md" heading="Querying feeds by creator name" tab="Node.js" index=1 + // search public feeds created by users with 'Thompson' in their name + await client.feeds.queryFeeds({ + filter: { + visibility: 'public', + 'created_by.name': { $q: 'Thompson' }, + }, + }); + // #endregion snippet + + // The filter matches the whole app: check the test's feeds. + const [response] = await queryResults(query); + expect(fids(response)).toContain(`user:${publicId}`); + expect(fids(response)).not.toContain(`user:${privateId}`); + expect( + response.feeds.every( + (f) => + f.visibility === 'public' && + /thompson/i.test(f.created_by.name ?? ''), + ), + ).toBe(true); + }); + + it('runs the complex example', async () => { + // A word only this test's feeds and users contain. + const searchQuery = `query${runId}`; + const creatorId = await newUser('creator', `Jane ${searchQuery}`); + const byNameId = newFeedId('byname'); + const byDescriptionId = newFeedId('bydescription'); + const byCreatorId = newFeedId('bycreator'); + await client.feeds.feed('user', byNameId).getOrCreate({ + user_id: ownerId, + data: { name: `Feed ${searchQuery}` }, + }); + await client.feeds.feed('user', byDescriptionId).getOrCreate({ + user_id: ownerId, + data: { description: `About ${searchQuery}` }, + }); + await client.feeds.feed('user', byCreatorId).getOrCreate({ + user_id: creatorId, + }); + const expected = [byNameId, byDescriptionId, byCreatorId] + .map((id) => `user:${id}`) + .sort(); + await retry(async () => { + const r = await client.feeds.queryFeeds({ + filter: { + $or: [ + { name: { $q: searchQuery } }, + { description: { $q: searchQuery } }, + { 'created_by.name': { $q: searchQuery } }, + ], + }, + }); + expect(fids(r).sort()).toEqual(expected); + }); + const query = vi.spyOn(client.feeds, 'queryFeeds'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/02-search-feeds.md" heading="A complex example" tab="Node.js" index=1 + await client.feeds.queryFeeds({ + filter: { + group_id: 'user', + $or: [ + { name: { $q: searchQuery } }, + { description: { $q: searchQuery } }, + { 'created_by.name': { $q: searchQuery } }, + ], + }, + }); + // #endregion snippet + + const [response] = await queryResults(query); + expect(fids(response).sort()).toEqual(expected); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/03-visibility.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/03-visibility.test.ts new file mode 100644 index 0000000..1934c5a --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/03-visibility.test.ts @@ -0,0 +1,127 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + AddActivityResponse, + GetOrCreateFeedResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { isFeedGroupMissing } from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/04-feeds-and-follows/03-visibility.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +describe(DOCS, () => { + let userId: string; + + beforeAll(async () => { + userId = uniqueId('owner'); + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates a public feed and a feed group with a default visibility', async () => { + const jackId = uniqueId('jack'); + cleanup.feeds.push(`user:${jackId}`); + const myGroupId = uniqueId('myid'); + cleanup.feedGroups.push(myGroupId); + const getOrCreate = vi.spyOn(client.feeds, 'getOrCreateFeed'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/03-visibility.md" heading="Feed Visibility Levels" tab="Node.js" index=1 + // COPY: jackId="jack", userId="", myGroupId="myid" + // More options + const feed = client.feeds.feed('user', jackId); + await feed.getOrCreate({ + data: { + visibility: 'public', + }, + // The owner of the feed + user_id: userId, + }); + + const response = await client.feeds.createFeedGroup({ + id: myGroupId, + default_visibility: 'public', + // Other settings... + }); + // #endregion snippet + + const created = await (getOrCreate.mock.results[0] + .value as Promise); + expect(created.feed).toMatchObject({ + feed: `user:${jackId}`, + visibility: 'public', + }); + expect(response.feed_group).toMatchObject({ + id: myGroupId, + default_visibility: 'public', + }); + + // New feeds of the group start with its default visibility. + const groupFeedId = uniqueId('groupfeed'); + cleanup.feeds.push(`${myGroupId}:${groupFeedId}`); + const groupFeed = await retry( + () => + client.feeds + .feed(myGroupId, groupFeedId) + .getOrCreate({ user_id: userId }), + { retryIf: isFeedGroupMissing }, + ); + expect(groupFeed.feed.visibility).toBe('public'); + }); + + it('adds an activity with tag visibility', async () => { + const feedId = uniqueId('feed'); + const user1Fid = `user:${feedId}`; + cleanup.feeds.push(user1Fid); + await client.feeds.feed('user', feedId).getOrCreate({ user_id: userId }); + const addActivity = vi.spyOn(client.feeds, 'addActivity'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/03-visibility.md" heading="Activity Visibility Levels" tab="Node.js" index=1 + // COPY: user1Fid="user:1", userId="" + await client.feeds.addActivity({ + feeds: [user1Fid], + type: 'post', + text: 'Premium content', + visibility: 'tag', + visibility_tag: 'premium', + user_id: userId, + }); + // Premium users can see full activity, others a preview + // #endregion snippet + + const { activity } = await (addActivity.mock.results[0] + .value as Promise); + expect(activity).toMatchObject({ + text: 'Premium content', + visibility: 'tag', + visibility_tag: 'premium', + }); + + // Users without the tag get a preview, the owner the full activity. + const viewerId = uniqueId('viewer'); + cleanup.users.push(viewerId); + await client.upsertUsers([{ id: viewerId }]); + const [asViewer, asOwner] = await Promise.all( + [viewerId, userId].map((user_id) => + client.feeds.feed('user', feedId).getOrCreate({ user_id }), + ), + ); + expect(asViewer.activities.map((a) => a.preview)).toEqual([true]); + expect(asOwner.activities.map((a) => a.preview)).toEqual([false]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/04-follows.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/04-follows.test.ts new file mode 100644 index 0000000..f497b4e --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/04-follows.test.ts @@ -0,0 +1,489 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { + AcceptFollowResponse, + FollowResponse, + RejectFollowResponse, + UnfollowBatchResponse, + UpdateFollowResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + isFeedGroupMissing, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +/** `uniqueId` users, registered for cleanup. */ +const createUsers = async (...names: string[]) => { + const ids = names.map((name) => uniqueId(name)); + cleanup.users.push(...ids); + await client.upsertUsers(ids.map((id) => ({ id }))); + return ids; +}; + +/** Creates `group:id` owned by `userId` and registers it for cleanup. */ +const createFeed = async (group: string, id: string, userId: string) => { + cleanup.feeds.push(`${group}:${id}`); + const feed = client.feeds.feed(group, id); + await retry(() => feed.getOrCreate({ user_id: userId }), { + retryIf: isFeedGroupMissing, + }); + return feed; +}; + +const followsFrom = async (source: string) => + ( + await client.feeds.queryFollows({ + filter: { source_feed: source }, + limit: 20, + }) + ).follows; + +const targets = (follows: FollowResponse[]) => + follows.map((f) => f.target_feed.feed).sort(); + +describe(DOCS, () => { + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('follows a user and a stock', async () => { + const [johnId, tomId] = await createUsers('john', 'tom'); + const stockGroup = uniqueId('stock'); + cleanup.feedGroups.push(stockGroup); + await client.feeds.createFeedGroup({ id: stockGroup }); + const tomFid = `user:${tomId}`; + await createFeed('user', tomId, tomId); + const stockFid = `${stockGroup}:${uniqueId('apple')}`; + await createFeed(stockGroup, stockFid.split(':')[1], tomId); + const otherStockFid = `${stockGroup}:${uniqueId('google')}`; + await createFeed(stockGroup, otherStockFid.split(':')[1], tomId); + cleanup.feeds.push(`timeline:${johnId}`); + retryOnFeedGroupMissing(client, ['follow']); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Follow" tab="Node.js" index=1 + // COPY: johnId="john", tomFid="user:tom", stockFid="stock:apple", otherStockFid="stock:google" + const timeline = client.feeds.feed('timeline', johnId); + await timeline.getOrCreate({ + user_id: johnId, + }); + + // Follow a user + await client.feeds.follow({ + source: timeline.feed, + target: tomFid, + }); + + // Follow a stock + await client.feeds.follow({ + source: timeline.feed, + target: stockFid, + }); + + // Follow with more fields + await client.feeds.follow({ + source: timeline.feed, + target: otherStockFid, + push_preference: 'all', + custom: { + reason: 'investment', + }, + }); + // #endregion snippet + + const follows = await followsFrom(timeline.feed); + expect(targets(follows)).toEqual([otherStockFid, stockFid, tomFid].sort()); + expect( + follows.find((f) => f.target_feed.feed === otherStockFid), + ).toMatchObject({ + push_preference: 'all', + custom: { reason: 'investment' }, + }); + }); + + it('auto-creates users on follow', async () => { + const aliceId = uniqueId('alice'); + const bobId = uniqueId('bob'); + cleanup.users.push(aliceId, bobId); + const aliceTimelineFid = `timeline:${aliceId}`; + const bobFid = `user:${bobId}`; + cleanup.feeds.push(aliceTimelineFid, bobFid); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Auto-creating users" tab="Node.js" index=1 + // COPY: aliceTimelineFid="timeline:alice", bobFid="user:bob" + await client.feeds.follow({ + source: aliceTimelineFid, + target: bobFid, + create_users: true, + }); + // #endregion snippet + + const { users } = await client.queryUsers({ + payload: { filter_conditions: { id: { $in: [aliceId, bobId] } } }, + }); + expect(users.map((u) => u.id).sort()).toEqual([aliceId, bobId].sort()); + expect(targets(await followsFrom(aliceTimelineFid))).toEqual([bobFid]); + }); + + it('auto-creates users on batch follows', async () => { + const aliceId = uniqueId('alice'); + const bobId = uniqueId('bob'); + const charlieId = uniqueId('charlie'); + cleanup.users.push(aliceId, bobId, charlieId); + const aliceTimelineFid = `timeline:${aliceId}`; + const bobFid = `user:${bobId}`; + const charlieFid = `user:${charlieId}`; + cleanup.feeds.push(aliceTimelineFid, bobFid, charlieFid); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Auto-creating users" tab="Node.js" index=2 + // COPY: aliceTimelineFid="timeline:alice", bobFid="user:bob", charlieFid="user:charlie" + await client.feeds.getOrCreateFollows({ + create_users: true, + follows: [ + { source: aliceTimelineFid, target: bobFid }, + { source: aliceTimelineFid, target: charlieFid }, + ], + }); + // #endregion snippet + + const { users } = await client.queryUsers({ + payload: { + filter_conditions: { id: { $in: [aliceId, bobId, charlieId] } }, + }, + }); + expect(users.map((u) => u.id).sort()).toEqual( + [aliceId, bobId, charlieId].sort(), + ); + expect(targets(await followsFrom(aliceTimelineFid))).toEqual( + [bobFid, charlieFid].sort(), + ); + }); + + it('unfollows a feed', async () => { + const [johnId, tomId] = await createUsers('john', 'tom'); + const timeline = await createFeed('timeline', johnId, johnId); + const tomFid = `user:${tomId}`; + await createFeed('user', tomId, tomId); + await client.feeds.follow({ source: timeline.feed, target: tomFid }); + expect(targets(await followsFrom(timeline.feed))).toEqual([tomFid]); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Unfollow" tab="Node.js" index=1 + // COPY: tomFid="user:tom" + await client.feeds.unfollow({ + source: timeline.feed, + target: tomFid, + }); + // #endregion snippet + + expect(await followsFrom(timeline.feed)).toEqual([]); + }); + + it('updates a follow', async () => { + const [sourceFeedId, targetFeedId] = await createUsers('source', 'target'); + await createFeed('timeline', sourceFeedId, sourceFeedId); + await createFeed('user', targetFeedId, targetFeedId); + await client.feeds.follow({ + source: `timeline:${sourceFeedId}`, + target: `user:${targetFeedId}`, + push_preference: 'all', + }); + const updateFollow = vi.spyOn(client.feeds, 'updateFollow'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Update follow" tab="Node.js" index=1 + 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 { follow } = await (updateFollow.mock.results[0] + .value as Promise); + expect(follow).toMatchObject({ + push_preference: 'none', + follower_role: 'feed_member_viewer', + custom: { note: 'Updated follow' }, + }); + }); + + it('queries follows', async () => { + const [johnId, saraId, adamId, followerId] = await createUsers( + 'john', + 'sara', + 'adam', + 'follower', + ); + const saraFid = `user:${saraId}`; + const adamFid = `user:${adamId}`; + await createFeed('user', saraId, saraId); + await createFeed('user', adamId, adamId); + const follower = await createFeed('timeline', followerId, followerId); + cleanup.feeds.push(`timeline:${johnId}`, `user:${johnId}`); + await client.feeds.follow({ + source: `timeline:${johnId}`, + target: saraFid, + }); + await client.feeds.follow({ + source: follower.feed, + target: `user:${johnId}`, + }); + const queryFollows = vi.spyOn(client.feeds, 'queryFollows'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Querying Follows" tab="Node.js" index=1 + // COPY: johnId="john", saraFid="user:sara", adamFid="user:adam" + const myTimeline = client.feeds.feed('timeline', johnId); + await myTimeline.getOrCreate({ + user_id: johnId, + }); + // Do I follow a list of feeds + const response = await client.feeds.queryFollows({ + filter: { + source_feed: myTimeline.feed, + target_feed: { $in: [saraFid, adamFid] }, + }, + }); + + console.log(response.follows); + + const userFeed = client.feeds.feed('user', johnId); + await userFeed.getOrCreate({ + user_id: johnId, + }); + + // Paginating through followers for a feed + const firstPage = await client.feeds.queryFollows({ + filter: { target_feed: userFeed.feed }, + limit: 20, + }); + + // Next page + const secondPage = await client.feeds.queryFollows({ + filter: { target_feed: userFeed.feed }, + limit: 20, + next: firstPage.next, + }); + + // Filter by source - feeds that I follow + await client.feeds.queryFollows({ + filter: { source_feed: myTimeline.feed }, + limit: 20, + }); + // #endregion snippet + + expect(targets(response.follows)).toEqual([saraFid]); + expect(log.mock.calls[0]).toEqual([response.follows]); + expect(firstPage.follows.map((f) => f.source_feed.feed)).toEqual([ + follower.feed, + ]); + expect(firstPage.next).toBeUndefined(); + expect(secondPage.follows.map((f) => f.source_feed.feed)).toEqual([ + follower.feed, + ]); + const bySource = await (queryFollows.mock.results[3].value as ReturnType< + typeof client.feeds.queryFollows + >); + expect(targets(bySource.follows)).toEqual([saraFid]); + }); + + it('sends, accepts and rejects a follow request', async () => { + const [saraId, adamId] = await createUsers('sara', 'adam'); + const uuidv4 = () => uniqueId('feed'); + const acceptFollow = vi.spyOn(client.feeds, 'acceptFollow'); + const rejectFollow = vi.spyOn(client.feeds, 'rejectFollow'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Follow Requests" tab="Node.js" index=1 + // COPY: saraId="sara", adamId="adam" + const saraFeed = client.feeds.feed('user', uuidv4()); + await saraFeed.getOrCreate({ + // You need to set followers visibility to have follow requests + data: { visibility: 'followers' }, + user_id: saraId, + }); + + const adamTimeline = client.feeds.feed('timeline', uuidv4()); + await adamTimeline.getOrCreate({ + user_id: adamId, + }); + + const followRequest = await client.feeds.follow({ + source: adamTimeline.feed, + target: saraFeed.feed, + }); + + console.log(followRequest.follow.status); // pending + + await client.feeds.acceptFollow({ + source: adamTimeline.feed, + target: saraFeed.feed, + // Optionally provide role + follower_role: 'feed_member_viewer', + }); + + await client.feeds.rejectFollow({ + source: adamTimeline.feed, + target: saraFeed.feed, + }); + // #endregion snippet + + cleanup.feeds.push(saraFeed.feed, adamTimeline.feed); + expect(followRequest.follow.status).toBe('pending'); + expect(log.mock.calls[0]).toEqual(['pending']); + const accepted = await (acceptFollow.mock.results[0] + .value as Promise); + expect(accepted.follow).toMatchObject({ + status: 'accepted', + follower_role: 'feed_member_viewer', + }); + const rejected = await (rejectFollow.mock.results[0] + .value as Promise); + expect(rejected.follow.status).toBe('rejected'); + }); + + it('gets follow suggestions', async () => { + const [johnId] = await createUsers('john'); + cleanup.feeds.push(`user:${johnId}`); + await createFeed('user', johnId, johnId); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Follow Suggestions" tab="Node.js" index=1 + // COPY: johnId="john" + // Get follow suggestions for a user + const suggestions = await client.feeds.getFollowSuggestions({ + feed_group_id: 'user', + limit: 10, + user_id: johnId, + }); + + console.log('Algorithm used:', suggestions.algorithm_used); + console.log('Duration:', suggestions.duration); + + suggestions.suggestions.forEach((suggestion) => { + console.log('Suggested feed:', suggestion.feed); + console.log('Name:', suggestion.name); + console.log('Description:', suggestion.description); + console.log('Follower count:', suggestion.follower_count); + console.log('Recommendation score:', suggestion.recommendation_score); + console.log('Reason:', suggestion.reason); + console.log('Algorithm scores:', suggestion.algorithm_scores); + }); + // #endregion snippet + + expect(suggestions.suggestions.length).toBeLessThanOrEqual(10); + expect( + suggestions.suggestions.every((s) => s.feed.startsWith('user:')), + ).toBe(true); + expect(log.mock.calls).toHaveLength(2 + 7 * suggestions.suggestions.length); + }); + + it('follows and unfollows idempotently', async () => { + const [aliceId, bobId] = await createUsers('alice', 'bob'); + const aliceTimelineFid = `timeline:${aliceId}`; + const bobFid = `user:${bobId}`; + await createFeed('timeline', aliceId, aliceId); + await createFeed('user', bobId, bobId); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Idempotent follow & unfollow" tab="Node.js" index=1 + // COPY: aliceTimelineFid="timeline:alice", bobFid="user:bob" + // Idempotent follow: returns the existing follow if one exists, otherwise creates it. + const { follow, created } = await client.feeds.getOrCreateFollow({ + source: aliceTimelineFid, + target: bobFid, + }); + console.log(created ? 'newly created' : 'already existed', follow); + + // Idempotent unfollow: no error if the follow does not exist. + const { follow: removed, deleted } = await client.feeds.getOrCreateUnfollow( + { + source: aliceTimelineFid, + target: bobFid, + }, + ); + console.log(deleted ? 'removed' : 'was not following', removed); + // #endregion snippet + + expect(created).toBe(true); + expect(follow.target_feed.feed).toBe(bobFid); + expect(deleted).toBe(true); + expect(removed?.target_feed.feed).toBe(bobFid); + expect(log.mock.calls.map((c) => c[0] as string)).toEqual([ + 'newly created', + 'removed', + ]); + expect(await followsFrom(aliceTimelineFid)).toEqual([]); + + // Calling both again doesn't fail and reports no change. + const again = await client.feeds.getOrCreateUnfollow({ + source: aliceTimelineFid, + target: bobFid, + }); + expect(again.deleted).toBe(false); + }); + + it('batch follows and unfollows', async () => { + const [timelineId, feedId, feed2Id] = await createUsers( + 'timeline', + 'feed', + 'feed2', + ); + const timeline = await createFeed('timeline', timelineId, timelineId); + const feed = await createFeed('user', feedId, feedId); + const feed2 = await createFeed('user', feed2Id, feed2Id); + const getOrCreateUnfollows = vi.spyOn(client.feeds, 'getOrCreateUnfollows'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/04-follows.md" heading="Batch follow & unfollow" tab="Node.js" index=1 + const response = await client.feeds.getOrCreateFollows({ + follows: [ + { + source: timeline.feed, + target: feed.feed, + // Optional + push_preference: 'all', + custom: { + reason: 'investment', + }, + }, + { + source: timeline.feed, + target: feed2.feed, + }, + ], + }); + + console.log('Created follows:', response.created); + console.log('Follows:', response.follows); + + await client.feeds.getOrCreateUnfollows({ + follows: [ + { + source: timeline.feed, + target: feed.feed, + }, + ], + }); + + console.log('Follows that were removed:', response.follows); + // #endregion snippet + + expect(targets(response.created)).toEqual([feed.feed, feed2.feed].sort()); + const unfollowed = await (getOrCreateUnfollows.mock.results[0] + .value as Promise); + expect(targets(unfollowed.follows)).toEqual([feed.feed]); + expect(log.mock.calls).toHaveLength(3); + expect(targets(await followsFrom(timeline.feed))).toEqual([feed2.feed]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/05-changing-feed-visibility.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/05-changing-feed-visibility.test.ts new file mode 100644 index 0000000..40249cb --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/05-changing-feed-visibility.test.ts @@ -0,0 +1,115 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + ChangeFeedVisibilityResponse, + SingleFollowResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/04-feeds-and-follows/05-changing-feed-visibility.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +describe(DOCS, () => { + let ownerId: string; + let followerId: string; + + beforeAll(async () => { + ownerId = uniqueId('owner'); + followerId = uniqueId('follower'); + cleanup.users.push(ownerId, followerId); + await client.upsertUsers([{ id: ownerId }, { id: followerId }]); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('changes the visibility of a feed', async () => { + const jackId = uniqueId('jack'); + const jackFid = `user:${jackId}`; + const timelineFid = `timeline:${followerId}`; + cleanup.feeds.push(jackFid, timelineFid); + await client.feeds.feed('user', jackId).getOrCreate({ user_id: ownerId }); + await client.feeds + .feed('timeline', followerId) + .getOrCreate({ user_id: followerId }); + + // After the first change (to `followers`), a follow request comes in, so the + // second change has a pending follow to process. + const follows: SingleFollowResponse[] = []; + const original = client.feeds.changeFeedVisibility.bind(client.feeds); + const change = vi + .spyOn(client.feeds, 'changeFeedVisibility') + .mockImplementation(async (request) => { + // A change right after another fails while the first is reconciled (code 4 + // "a visibility change is already in progress for this feed"). + const response = await retry(() => original(request), { + retryIf: (error) => String(error).includes('already in progress'), + }); + if (request.visibility === 'followers') { + // Until the change is applied, a follow is still accepted at once: undo + // it and retry until the follow request stays pending. + follows.push( + await retry(async () => { + const follow = await client.feeds.follow({ + source: timelineFid, + target: jackFid, + }); + if (follow.follow.status !== 'pending') { + await client.feeds.unfollow({ + source: timelineFid, + target: jackFid, + }); + throw new Error(`follow is ${follow.follow.status}`); + } + return follow; + }), + ); + } + return response; + }); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/05-changing-feed-visibility.md" heading="Examples" tab="Node.js" index=1 + // COPY: jackId="jack" + const feed = client.feeds.feed('user', jackId); + + await feed.changeFeedVisibility({ + visibility: 'followers', + }); + + // Process pending follow requests while loosening from followers + await feed.changeFeedVisibility({ + visibility: 'visible', + pending_follows_action: 'auto_approve', + }); + // #endregion snippet + + // The follow request was pending on the `followers` feed. + expect(follows.map((f) => f.follow.status)).toEqual(['pending']); + const [toFollowers, toVisible] = await Promise.all( + change.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(toFollowers.feed.visibility).toBe('followers'); + expect(toVisible.feed.visibility).toBe('visible'); + // Loosening with `auto_approve` accepts it (reconciled in the background). + await retry(async () => { + const { follows: current } = await client.feeds.queryFollows({ + filter: { source_feed: timelineFid, target_feed: jackFid }, + }); + expect(current.map((f) => f.status)).toEqual(['accepted']); + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/05-membership-levels.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/05-membership-levels.test.ts new file mode 100644 index 0000000..2a104bf --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/05-membership-levels.test.ts @@ -0,0 +1,334 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + AddActivityResponse, + CreateMembershipLevelResponse, + MembershipLevelResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/04-feeds-and-follows/05-membership-levels.md'; + +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +// Membership levels are app-wide and not in the leak check: every id comes +// from `uniqueId`, is deleted after the feeds (a level in use by a feed member +// can't be deleted) and checked to be gone afterwards. +const levelIds: string[] = []; +const uuidv4 = () => { + const id = uniqueId('level'); + levelIds.push(id); + return id; +}; +const deleteLevels = async () => { + for (const id of levelIds) { + await retry( + () => + client.feeds.deleteMembershipLevel({ id }).catch((error: unknown) => { + if (!String(error).includes("can't be found")) throw error; + }), + { timeout: 30000, retryIf: (e) => String(e).includes('in use') }, + ); + } +}; + +const levelsById = async () => + ( + await client.feeds.queryMembershipLevels({ + filter: { id: { $in: levelIds } }, + limit: 25, + }) + ).membership_levels; + +const creatorId = uniqueId('creator'); +const premiumFanId = uniqueId('premium-fan'); +const viewerId = uniqueId('viewer'); +const creator = { id: creatorId }; +const premiumFan = { id: premiumFanId }; +const creatorFeed = client.feeds.feed('user', creatorId); + +let premiumMembershipLevel: MembershipLevelResponse; +let innerCircleMembershipLevel: MembershipLevelResponse; + +describe(DOCS, () => { + beforeAll(async () => { + cleanup.users.push(creatorId, premiumFanId, viewerId); + await client.upsertUsers([ + { id: creatorId }, + { id: premiumFanId }, + { id: viewerId }, + ]); + cleanup.feeds.push(creatorFeed.feed); + await creatorFeed.getOrCreate({ user_id: creatorId }); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(async () => { + try { + await cleanup.run(); + } finally { + await deleteLevels(); + } + expect(levelIds.length).toBeGreaterThan(0); + expect(await levelsById()).toEqual([]); + }); + + it('creates membership levels', async () => { + const create = vi.spyOn(serverClient.feeds, 'createMembershipLevel'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/05-membership-levels.md" heading="Create membership levels" tab="Node.js" index=1 + await serverClient.feeds.createMembershipLevel({ + id: uuidv4(), + name: 'Premium', + description: 'Weekly QA with me', + custom: { + // Lets you query membership levels by creator.id, useful if you want to scope membership levels to users + creator_id: creator.id, + }, + // Lets you sort membership levels by priority + priority: 100, + // Users with this membership level can see activities with these tags + tags: ['premium'], + }); + + await serverClient.feeds.createMembershipLevel({ + id: uuidv4(), + name: 'Inner Circle', + description: `You can see new videos a week earlier, and weekly QA with me`, + // Users in this membership level can see premium and inner circle content as well + tags: ['premium', 'inner-circle'], + priority: 200, + custom: { + creator_id: creator.id, + }, + }); + // #endregion snippet + + const [premium, innerCircle] = await Promise.all( + create.mock.results.map( + async (r) => + (await (r.value as Promise)) + .membership_level, + ), + ); + premiumMembershipLevel = premium; + innerCircleMembershipLevel = innerCircle; + expect(premium).toMatchObject({ + id: levelIds[0], + name: 'Premium', + priority: 100, + tags: ['premium'], + custom: { creator_id: creatorId }, + }); + expect(innerCircle).toMatchObject({ + id: levelIds[1], + name: 'Inner Circle', + priority: 200, + tags: ['premium', 'inner-circle'], + }); + expect((await levelsById()).map((l) => l.id).sort()).toEqual( + [...levelIds].sort(), + ); + }); + + it('assigns a membership level to a user', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/05-membership-levels.md" heading="Assign membership level to users" tab="Node.js" index=1 + await serverClient.feeds.updateFeedMembers({ + feed_group_id: creatorFeed.group, + feed_id: creatorFeed.id, + operation: 'upsert', + members: [ + { + user_id: premiumFan.id, + membership_level: premiumMembershipLevel.id, + role: 'feed_member_viewer', + }, + ], + }); + // #endregion snippet + + const { members } = await creatorFeed.queryFeedMembers({}); + expect( + members.map((m) => ({ + user: m.user.id, + role: m.role, + level: m.membership_level?.id, + status: m.status, + })), + ).toEqual([ + { + user: premiumFanId, + role: 'feed_member_viewer', + level: premiumMembershipLevel.id, + status: 'member', + }, + ]); + }); + + it('creates a premium activity', async () => { + const creatorFid = creatorFeed.feed; + const userId = creatorId; + const addActivity = vi.spyOn(client.feeds, 'addActivity'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/05-membership-levels.md" heading="Create a premium activity" tab="Node.js" index=1 + // COPY: creatorFid="user:1", userId="" + await client.feeds.addActivity({ + feeds: [creatorFid], + type: 'post', + text: 'Premium content', + visibility: 'tag', + visibility_tag: 'premium', + user_id: userId, + }); + // Premium users can see full activity, others a preview + // #endregion snippet + + const { activity } = await (addActivity.mock.results[0] + .value as Promise); + expect(activity).toMatchObject({ + visibility: 'tag', + visibility_tag: 'premium', + }); + + // The premium member gets the full activity, a non-member a preview. + const previews = async (user_id: string) => + (await creatorFeed.getOrCreate({ user_id })).activities.map((a) => ({ + preview: a.preview, + text: a.text, + })); + await retry(async () => { + expect(await previews(premiumFanId)).toEqual([ + { preview: false, text: 'Premium content' }, + ]); + }); + const asViewer = await previews(viewerId); + expect(asViewer.map((a) => a.preview)).toEqual([true]); + }); + + it('queries membership levels', async () => { + const query = vi.spyOn(serverClient.feeds, 'queryMembershipLevels'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/05-membership-levels.md" heading="Query membership levels" tab="Node.js" index=1 + await serverClient.feeds.queryMembershipLevels({ + filter: { + custom: { + $contains: { + creator_id: creator.id, + }, + }, + }, + sort: [{ field: 'priority', direction: -1 }], + }); + // #endregion snippet + + const { membership_levels } = await (query.mock.results[0] + .value as ReturnType); + expect(membership_levels.map((l) => l.id)).toEqual([ + innerCircleMembershipLevel.id, + premiumMembershipLevel.id, + ]); + }); + + it('updates a membership level', async () => { + // API behavior: a level that was ever assigned to a feed member can't be + // updated (500, code -1, empty message), even after the member is removed. + // Record that error and show the same request works on a level that was + // never assigned. + const updateMembershipLevel = client.feeds.updateMembershipLevel.bind( + client.feeds, + ); + const errors: unknown[] = []; + vi.spyOn(serverClient.feeds, 'updateMembershipLevel').mockImplementation( + async (request) => { + try { + return await updateMembershipLevel(request); + } catch (error) { + errors.push(error); + return undefined as never; + } + }, + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/05-membership-levels.md" heading="Update membership levels" tab="Node.js" index=1 + await serverClient.feeds.updateMembershipLevel({ + id: premiumMembershipLevel.id, + name: 'Premium', + description: 'Weekly QA with me', + custom: { + creator_id: creator.id, + }, + priority: 100, + tags: ['premium'], + }); + // #endregion snippet + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: -1, + metadata: { responseCode: 500 }, + }); + + const neverAssignedId = uuidv4(); + await client.feeds.createMembershipLevel({ + id: neverAssignedId, + name: 'Old name', + description: 'Old description', + priority: 1, + tags: ['old'], + }); + const { membership_level } = await updateMembershipLevel({ + id: neverAssignedId, + name: 'Premium', + description: 'Weekly QA with me', + custom: { + creator_id: creator.id, + }, + priority: 100, + tags: ['premium'], + }); + expect(membership_level).toMatchObject({ + id: neverAssignedId, + name: 'Premium', + description: 'Weekly QA with me', + custom: { creator_id: creatorId }, + priority: 100, + tags: ['premium'], + }); + }); + + it('deletes a membership level', async () => { + // A level in use by a feed member can't be deleted (code 17 "Cannot delete + // membership level that is currently in use by feed members"). + await creatorFeed.updateFeedMembers({ + operation: 'remove', + members: [{ user_id: premiumFanId }], + }); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/05-membership-levels.md" heading="Delete membership level" tab="Node.js" index=1 + await serverClient.feeds.deleteMembershipLevel({ + id: premiumMembershipLevel.id, + }); + // #endregion snippet + + await retry(async () => { + expect((await levelsById()).map((l) => l.id)).not.toContain( + premiumMembershipLevel.id, + ); + }); + expect((await levelsById()).map((l) => l.id)).toContain( + innerCircleMembershipLevel.id, + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/06-feed-members.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/06-feed-members.test.ts new file mode 100644 index 0000000..4c8511f --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/04-feeds-and-follows/06-feed-members.test.ts @@ -0,0 +1,205 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { + AcceptFeedMemberInviteResponse, + FeedMemberResponse, + QueryFeedMembersResponse, + RejectFeedMemberInviteResponse, + UpdateFeedMembersResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = + 'activity-feeds/v3-latest/_default/04-feeds-and-follows/06-feed-members.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +/** `uniqueId` users, registered for cleanup. */ +const createUsers = async (...names: string[]) => { + const ids = names.map((name) => uniqueId(name)); + cleanup.users.push(...ids); + await client.upsertUsers(ids.map((id) => ({ id }))); + return ids; +}; + +/** A `user` feed owned by a new `uniqueId` user, registered for cleanup. */ +const createFeed = async () => { + const [ownerId] = await createUsers('owner'); + const feed = client.feeds.feed('user', ownerId); + cleanup.feeds.push(feed.feed); + await feed.getOrCreate({ user_id: ownerId }); + return feed; +}; + +const summary = (members: FeedMemberResponse[]) => + members + .map((m) => ({ user: m.user.id, role: m.role, status: m.status })) + .sort((a, b) => a.user.localeCompare(b.user)); + +describe(DOCS, () => { + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('manages members', async () => { + const feed = await createFeed(); + const [johnId, janeId] = await createUsers('john', 'jane'); + const update = vi.spyOn(client.feeds, 'updateFeedMembers'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/06-feed-members.md" heading="Manage members" tab="Node.js" index=1 + // COPY: johnId="john", janeId="jane" + // The following methods are available to add or edit the members of a feed + await feed.updateFeedMembers({ + operation: 'upsert', + members: [ + { + user_id: johnId, + role: 'feed_moderator', + custom: { + joined: '2024-01-01', + }, + }, + ], + }); + + // Remove members + await feed.updateFeedMembers({ + operation: 'remove', + members: [ + { + user_id: johnId, + }, + { + user_id: janeId, + }, + ], + }); + + // Set members (overwrites the list) + await feed.updateFeedMembers({ + operation: 'set', + members: [ + { + user_id: johnId, + role: 'feed_moderator', + }, + ], + }); + // #endregion snippet + + const [upserted, removed, set] = await Promise.all( + update.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(upserted.added).toMatchObject([ + { + user: { id: johnId }, + role: 'feed_moderator', + custom: { joined: '2024-01-01' }, + status: 'member', + }, + ]); + // Jane isn't a member: removing her is a no-op. + expect(removed.removed_ids).toEqual([johnId]); + expect(set.added.map((m) => m.user.id)).toEqual([johnId]); + + const { members } = await feed.queryFeedMembers({}); + expect(summary(members)).toEqual([ + { user: johnId, role: 'feed_moderator', status: 'member' }, + ]); + }); + + it('invites members', async () => { + const feed = await createFeed(); + const [johnId, janeId] = await createUsers('john', 'jane'); + const accept = vi.spyOn(client.feeds, 'acceptFeedMemberInvite'); + const reject = vi.spyOn(client.feeds, 'rejectFeedMemberInvite'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/06-feed-members.md" heading="Member invites" tab="Node.js" index=1 + // COPY: johnId="john", janeId="jane" + await feed.updateFeedMembers({ + operation: 'upsert', + members: [ + { + user_id: johnId, + role: 'feed_moderator', + invite: true, + custom: { + reason: 'community builder', + }, + }, + { + user_id: janeId, + invite: true, + }, + ], + }); + + await feed.acceptFeedMemberInvite({ + user_id: johnId, + }); + + await feed.rejectFeedMemberInvite({ + user_id: janeId, + }); + // #endregion snippet + + const { member: accepted } = await (accept.mock.results[0] + .value as Promise); + expect(accepted).toMatchObject({ + user: { id: johnId }, + role: 'feed_moderator', + status: 'member', + custom: { reason: 'community builder' }, + }); + const { member: rejected } = await (reject.mock.results[0] + .value as Promise); + expect(rejected).toMatchObject({ + user: { id: janeId }, + status: 'rejected', + }); + + // The rejected row stays; only accepted members are returned by default. + const all = await feed.queryFeedMembers({ + filter: { status: { $in: ['member', 'pending', 'rejected'] } }, + }); + expect(summary(all.members).map((m) => [m.user, m.status])).toEqual( + [ + [johnId, 'member'], + [janeId, 'rejected'], + ].sort((a, b) => a[0].localeCompare(b[0])), + ); + const acceptedOnly = await feed.queryFeedMembers({}); + expect(acceptedOnly.members.map((m) => m.user.id)).toEqual([johnId]); + }); + + it('queries feed members', async () => { + const feed = await createFeed(); + const [moderatorId, feedModeratorId] = await createUsers( + 'moderator', + 'feed-moderator', + ); + await feed.updateFeedMembers({ + operation: 'upsert', + members: [ + { user_id: moderatorId, role: 'moderator' }, + { user_id: feedModeratorId, role: 'feed_moderator' }, + ], + }); + const query = vi.spyOn(client.feeds, 'queryFeedMembers'); + + // #region snippet docs="activity-feeds/v3-latest/_default/04-feeds-and-follows/06-feed-members.md" heading="Query Feed Members" tab="Node.js" index=1 + await feed.queryFeedMembers({ + filter: { + role: 'moderator', + }, + }); + // #endregion snippet + + const { members } = await (query.mock.results[0] + .value as Promise); + expect(members.map((m) => m.user.id)).toEqual([moderatorId]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/01-activities.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/01-activities.test.ts new file mode 100644 index 0000000..81be65d --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/01-activities.test.ts @@ -0,0 +1,438 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + AddActivityResponse, + DeleteActivitiesResponse, + DeleteActivityResponse, + UpsertActivitiesResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { isFeedGroupMissing } from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'activity-feeds/v3-latest/_default/05-activities/01-activities.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +/** + * A fid in `group` with a uniqueId id, registered for cleanup. The API auto-creates + * a missing feed on add activity only when a user with the feed's id exists (code 113 + * "Missing feed ... can't be created: user not found"), so the user is created too. + */ +const newFid = async (group: string, name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + const fid = `${group}:${id}`; + cleanup.feeds.push(fid); + return fid; +}; +const newUserFid = (name: string) => newFid('user', name); + +describe(DOCS, () => { + let userId: string; + // The docs' "stock" feed group: a uniqueId group with default settings. + let stockGroup: string; + + const newStockFid = (name: string) => newFid(stockGroup, name); + + /** Adds a setup activity to the owner's `user` feed and returns it. */ + const createActivity = async (text = 'setup') => { + const { activity } = await client.feeds.addActivity({ + feeds: [`user:${userId}`], + type: 'post', + text, + user_id: userId, + }); + return activity; + }; + + beforeAll(async () => { + userId = uniqueId('owner'); + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + cleanup.feeds.push(`user:${userId}`); + + stockGroup = uniqueId('stock'); + cleanup.feedGroups.push(stockGroup); + await client.feeds.createFeedGroup({ id: stockGroup }); + // The new group isn't known to every API node at once: wait until several + // reads in a row find it, so the regions can add activities to it as written. + const probeId = uniqueId('probe'); + cleanup.feeds.push(`${stockGroup}:${probeId}`); + for (let read = 0; read < 5; read++) { + await retry( + () => + client.feeds.getOrCreateFeed({ + feed_group_id: stockGroup, + feed_id: probeId, + user_id: userId, + }), + { retryIf: isFeedGroupMissing }, + ); + } + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates an activity', async () => { + const user1Fid = await newUserFid('user1'); + const appleFid = await newStockFid('apple'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Creating Activities" tab="Node.js" index=1 + // COPY: user1Fid="user:1", appleFid="stock:apple", userId="" + // Add an activity to 1 feed or multiple feeds + const response = await client.feeds.addActivity({ + feeds: [user1Fid, appleFid], + type: 'post', + text: 'apple stock will go up', + // Provide user id, the owner of the activity + user_id: userId, + }); + + console.log(response.activity); + // #endregion snippet + + expect(log).toHaveBeenCalledWith(response.activity); + expect(response.activity).toMatchObject({ + type: 'post', + text: 'apple stock will go up', + user: { id: userId }, + }); + expect([...response.activity.feeds].sort()).toEqual( + [user1Fid, appleFid].sort(), + ); + // The feeds were created automatically. + const stored = await client.feeds.getActivity({ id: response.activity.id }); + expect(stored.activity.id).toBe(response.activity.id); + }); + + it('creates an image activity', async () => { + const user1Fid = await newUserFid('user1'); + const addActivity = vi.spyOn(client.feeds, 'addActivity'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Image & Video" tab="Node.js" index=1 + // COPY: user1Fid="user:1", userId="" + await client.feeds.addActivity({ + feeds: [user1Fid], + type: 'post', + text: 'look at NYC', + attachments: [ + { + type: 'image', + image_url: 'https://example.com/image.png', + custom: {}, + }, + ], + // Provide user id, the owner of the activity + user_id: userId, + }); + // #endregion snippet + + const { activity } = await (addActivity.mock.results[0] + .value as Promise); + expect(activity.text).toBe('look at NYC'); + expect(activity.attachments).toMatchObject([ + { type: 'image', image_url: 'https://example.com/image.png' }, + ]); + }); + + it('shares an activity', async () => { + const user1Fid = await newUserFid('user1'); + const activityToShare = await createActivity('original'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Sharing activities" tab="Node.js" index=1 + // COPY: user1Fid="user:1", userId="" + // Add an activity to 1 feed or multiple feeds + const response = await client.feeds.addActivity({ + feeds: [user1Fid], + type: 'post', + text: `Couldn't agree more!`, + parent_id: activityToShare.id, + // Provide user id, the owner of the activity + user_id: userId, + }); + + console.log(response.activity?.parent); + // #endregion snippet + + expect(response.activity.parent?.id).toBe(activityToShare.id); + expect(log).toHaveBeenCalledWith(response.activity.parent); + const parent = await client.feeds.getActivity({ id: activityToShare.id }); + expect(parent.activity.share_count).toBe(1); + }); + + it('restricts comment replies', async () => { + const user1Fid = await newUserFid('user1'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Restricting comment replies" tab="Node.js" index=1 + // COPY: user1Fid="user:1", userId="" + const response = await client.feeds.addActivity({ + feeds: [user1Fid], + type: 'post', + text: 'apple stock will go up', + user_id: userId, + restrict_replies: 'people_i_follow', // Options: "everyone", "people_i_follow", "nobody" + }); + // #endregion snippet + + expect(response.activity.restrict_replies).toBe('people_i_follow'); + }); + + it('adds many activities', async () => { + const user123Fid = await newUserFid('user123'); + const user456Fid = await newUserFid('user456'); + const activity1Id = uniqueId('activity1'); + const activity2Id = uniqueId('activity2'); + const upsert = vi.spyOn(client.feeds, 'upsertActivities'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Adding Many Activities" tab="Node.js" index=1 + // COPY: user123Fid="user:123", user456Fid="user:456", activity1Id="1", activity2Id="2", userId="" + await client.feeds.upsertActivities({ + activities: [ + { + feeds: [user123Fid], + id: activity1Id, + type: 'post', + text: 'hi', + user_id: userId, + }, + { + feeds: [user456Fid], + id: activity2Id, + type: 'post', + text: 'hi', + user_id: userId, + }, + ], + }); + // #endregion snippet + + const { activities } = await (upsert.mock.results[0] + .value as Promise); + expect(activities.map((a) => [a.id, a.feeds])).toEqual([ + [activity1Id, [user123Fid]], + [activity2Id, [user456Fid]], + ]); + }); + + it('adds an activity with tag visibility', async () => { + const user1Fid = await newUserFid('user1'); + const addActivity = vi.spyOn(client.feeds, 'addActivity'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Visibility Levels" tab="Node.js" index=1 + // COPY: user1Fid="user:1", userId="" + await client.feeds.addActivity({ + feeds: [user1Fid], + type: 'post', + text: 'Premium content', + visibility: 'tag', + visibility_tag: 'premium', + user_id: userId, + }); + // Premium users can see full activity, others a preview + // #endregion snippet + + const { activity } = await (addActivity.mock.results[0] + .value as Promise); + expect(activity).toMatchObject({ + visibility: 'tag', + visibility_tag: 'premium', + }); + }); + + it('partially updates an activity', async () => { + const { activity: created } = await client.feeds.addActivity({ + feeds: [await newUserFid('partial')], + type: 'post', + text: 'old text', + custom: { color: 'red', size: 'L' }, + user_id: userId, + }); + const activity = created; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Partial activity updates" tab="Node.js" index=1 + // COPY: userId="" + // Partially set some fields + const response = await client.feeds.updateActivityPartial({ + id: activity.id, + set: { + text: 'Japan has over 6,800 islands.', + }, + user_id: userId, + run_activity_processors: true, // Run processors if text/attachments changed + }); + console.log(response.activity.edited_at); + + // Partially unset some fields + const unsetResponse = await client.feeds.updateActivityPartial({ + id: activity.id, + unset: ['custom.color'], + user_id: userId, + }); + console.log(unsetResponse.activity.edited_at); + // #endregion snippet + + expect(response.activity.text).toBe('Japan has over 6,800 islands.'); + expect(response.activity.custom).toEqual({ color: 'red', size: 'L' }); + expect(unsetResponse.activity.text).toBe('Japan has over 6,800 islands.'); + expect(unsetResponse.activity.custom).toEqual({ size: 'L' }); + expect(unsetResponse.activity.edited_at).toBeInstanceOf(Date); + expect(log).toHaveBeenCalledTimes(2); + }); + + it('updates an activity', async () => { + const { id: activityId } = await createActivity('old text'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Updating Activities" tab="Node.js" index=1 + // COPY: activityId="123", userId="" + // NOTE: updateActivity does a full replace of the activity. + // Use updateActivityPartial if you only want to update specific fields. + const response = await client.feeds.updateActivity({ + id: activityId, + text: 'Updated text', + user_id: userId, + run_activity_processors: true, // Run processors if text/attachments changed + }); + console.log(response.activity.edited_at); + // #endregion snippet + + expect(response.activity.text).toBe('Updated text'); + expect(response.activity.edited_at).toBeInstanceOf(Date); + expect(log).toHaveBeenCalledWith(response.activity.edited_at); + }); + + it('deletes activities', async () => { + const { id: activityId } = await createActivity(); + const { id: activity456Id } = await createActivity(); + const deleteActivity = vi.spyOn(client.feeds, 'deleteActivity'); + const deleteActivities = vi.spyOn(client.feeds, 'deleteActivities'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Deleting Activities" tab="Node.js" index=1 + // COPY: activityId="123", activity456Id="456", userId="" + await client.feeds.deleteActivity({ + id: activityId, + hard_delete: false, // Soft delete sets deleted_at but retains the data, hard delete fully removes it + }); + + // Batch delete activities + await client.feeds.deleteActivities({ + ids: [activityId, activity456Id], + user_id: userId, + hard_delete: false, + }); + // #endregion snippet + + await (deleteActivity.mock.results[0] + .value as Promise); + await (deleteActivities.mock.results[0] + .value as Promise); + for (const id of [activityId, activity456Id]) { + await expect(client.feeds.getActivity({ id })).rejects.toMatchObject({ + metadata: { responseCode: 404 }, + }); + const { activity } = await client.feeds.getActivity({ + id, + include_soft_deleted_activities: true, + }); + expect(activity.deleted_at).toBeInstanceOf(Date); + } + }); + + it('restores an activity', async () => { + const { id: activityId } = await createActivity('restore me'); + await client.feeds.deleteActivity({ id: activityId, hard_delete: false }); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Restore Activity" tab="Node.js" index=1 + // COPY: activityId="123", userId="" + // Restore a soft-deleted activity + const restoredActivity = await client.feeds.restoreActivity({ + id: activityId, + user_id: userId, + }); + // #endregion snippet + + expect(restoredActivity.activity.id).toBe(activityId); + expect(restoredActivity.activity.deleted_at).toBeUndefined(); + const { activity } = await client.feeds.getActivity({ id: activityId }); + expect(activity.text).toBe('restore me'); + }); + + it('gets an activity', async () => { + const { id: activityId } = await createActivity('get me'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Get activity" tab="Node.js" index=1 + // COPY: activityId="activity123" + const activity = await client.feeds.getActivity({ id: activityId }); + // #endregion snippet + + expect(activity.activity).toMatchObject({ id: activityId, text: 'get me' }); + }); + + it('gets a soft-deleted activity', async () => { + const { id: activityId } = await createActivity('deleted'); + await client.feeds.deleteActivity({ id: activityId, hard_delete: false }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Get a moderated or soft-deleted activity" tab="Node.js" index=1 + // COPY: activityId="activity_123" + const { activity } = await client.feeds.getActivity({ + id: activityId, + include_moderated_activities: true, + include_soft_deleted_activities: true, + }); + console.log(activity.moderation_action, activity.deleted_at); + // #endregion snippet + + expect(activity.id).toBe(activityId); + expect(activity.deleted_at).toBeInstanceOf(Date); + expect(log).toHaveBeenCalledWith(undefined, activity.deleted_at); + }); + + it('adds an activity to multiple feeds', async () => { + const user1Fid = await newUserFid('user1'); + const appleFid = await newStockFid('apple'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/01-activities.md" heading="Adding activities to multiple feeds" tab="Node.js" index=1 + // COPY: user1Fid="user:1", appleFid="stock:apple", userId="" + // Add an activity to 1 feed or multiple feeds + const response = await client.feeds.addActivity({ + feeds: [user1Fid, appleFid], + type: 'post', + text: 'apple stock will go up', + // Provide user id, the owner of the activity + user_id: userId, + }); + + console.log(response.activity); + // #endregion snippet + + expect(log).toHaveBeenCalledWith(response.activity); + expect([...response.activity.feeds].sort()).toEqual( + [user1Fid, appleFid].sort(), + ); + // Feeds created by the add activity call itself have the activity. + const apple = await client.feeds.getOrCreateFeed({ + feed_group_id: stockGroup, + feed_id: appleFid.split(':')[1], + user_id: userId, + }); + expect(apple.created).toBe(false); + expect(apple.activities.map((a) => a.id)).toEqual([response.activity.id]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/02-querying-activities.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/02-querying-activities.test.ts new file mode 100644 index 0000000..edd2794 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/02-querying-activities.test.ts @@ -0,0 +1,343 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + QueryActivitiesRequest, + QueryActivitiesResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/05-activities/02-querying-activities.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +/** Ids of the activities a query returns. */ +const ids = (response: QueryActivitiesResponse) => + response.activities.map((a) => a.id); + +/** Queries until the result contains every id in `expected` (search is eventually consistent). */ +const queryUntilFound = (request: QueryActivitiesRequest, expected: string[]) => + retry(async () => { + const response = await client.feeds.queryActivities(request); + expect(ids(response)).toEqual(expect.arrayContaining(expected)); + return response; + }); + +/** Awaits the results of a spied `queryActivities`. */ +const spiedResults = (spy: { mock: { results: { value: unknown }[] } }) => + Promise.all( + spy.mock.results.map((r) => r.value as Promise), + ); + +describe(DOCS, () => { + let userId: string; + let fid: string; + + /** Adds an activity by the owner to the owner's `user` feed and returns its id. */ + const createActivity = async ( + extra: Partial[0]> = {}, + ) => { + const { activity } = await client.feeds.addActivity({ + feeds: [fid], + type: 'post', + text: 'setup', + user_id: userId, + ...extra, + }); + return activity.id; + }; + + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + beforeAll(async () => { + userId = await createUser('owner'); + fid = `user:${userId}`; + cleanup.feeds.push(fid); + await client.feeds.feed('user', userId).getOrCreate({ user_id: userId }); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('queries activities', async () => { + const activityId = await createActivity(); + await queryUntilFound({ filter: { id: activityId } }, [activityId]); + const query = vi.spyOn(client.feeds, 'queryActivities'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/02-querying-activities.md" heading="Examples" tab="Node.js" index=1 + await client.feeds.queryActivities({ + filter: { + activity_type: 'post', + }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 10, + }); + // #endregion snippet + + const [response] = await spiedResults(query); + expect(response.activities.length).toBeGreaterThan(0); + expect(response.activities.length).toBeLessThanOrEqual(10); + expect(response.activities.every((a) => a.type === 'post')).toBe(true); + // Newest first: the activity just created comes first. + expect(response.activities[0].id).toBe(activityId); + const times = response.activities.map((a) => a.created_at.getTime()); + expect(times).toEqual([...times].sort((a, b) => b - a)); + }); + + it('queries activities by text', async () => { + const matchId = await createActivity({ text: 'popularity contest' }); + const otherId = await createActivity({ text: 'something else' }); + await queryUntilFound( + { filter: { user_id: userId, text: { $q: 'popularity' } } }, + [matchId], + ); + const query = vi.spyOn(client.feeds, 'queryActivities'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/02-querying-activities.md" heading="Querying activities by text" tab="Node.js" index=1 + await client.feeds.queryActivities({ + filter: { + text: { + $q: 'popularity', + }, + }, + }); + // #endregion snippet + + const [response] = await spiedResults(query); + expect(ids(response)).toContain(matchId); + expect(ids(response)).not.toContain(otherId); + }); + + it('queries activities by search data', async () => { + const mallId = await createActivity({ + text: 'Check out our spring sale!', + search_data: { + campaign: { + id: 'spring-sale-2025', + location: { mall: 'yorkdale', city: 'toronto', country: 'canada' }, + }, + }, + }); + const onlineId = await createActivity({ + text: 'Spring sale online', + search_data: { campaign: { id: 'spring-sale-2025' } }, + }); + await queryUntilFound( + { + filter: { + user_id: userId, + search_data: { $contains: { campaign: { id: 'spring-sale-2025' } } }, + }, + }, + [mallId, onlineId], + ); + const query = vi.spyOn(client.feeds, 'queryActivities'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/02-querying-activities.md" heading="Querying activities by search data" tab="Node.js" index=1 + await client.feeds.queryActivities({ + filter: { + search_data: { $contains: { campaign: { id: 'spring-sale-2025' } } }, + }, + }); + + await client.feeds.queryActivities({ + filter: { + search_data: { $path_exists: 'campaign.location.mall' }, + }, + }); + // #endregion snippet + + const [byCampaign, byMall] = await spiedResults(query); + expect(ids(byCampaign)).toEqual(expect.arrayContaining([mallId, onlineId])); + expect(ids(byMall)).toContain(mallId); + expect(ids(byMall)).not.toContain(onlineId); + }); + + it('queries private activities', async () => { + const privateId = await createActivity({ + text: 'private', + visibility: 'private', + }); + await queryUntilFound( + { include_private_activities: true, filter: { id: privateId } }, + [privateId], + ); + // Without the flag the private activity isn't returned. + const without = await client.feeds.queryActivities({ + filter: { id: privateId }, + }); + expect(ids(without)).toEqual([]); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/02-querying-activities.md" heading="Querying private activities" tab="Node.js" index=1 + const response = await client.feeds.queryActivities({ + include_private_activities: true, + filter: { + // your filter here + }, + }); + // #endregion snippet + + // An empty filter matches the whole app; the newest activity comes first. + expect(ids(response)).toContain(privateId); + }); + + it('queries soft-deleted activities', async () => { + const deletedId = await createActivity({ text: 'deleted' }); + await client.feeds.deleteActivity({ id: deletedId, hard_delete: false }); + await queryUntilFound( + { include_soft_deleted_activities: true, filter: { id: deletedId } }, + [deletedId], + ); + const without = await client.feeds.queryActivities({ + filter: { id: deletedId }, + }); + expect(ids(without)).toEqual([]); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/02-querying-activities.md" heading="Querying soft-deleted activities" tab="Node.js" index=1 + const response = await client.feeds.queryActivities({ + include_soft_deleted_activities: true, + filter: { + // your filter here + }, + }); + // #endregion snippet + + const deleted = response.activities.find((a) => a.id === deletedId); + expect(deleted?.deleted_at).toBeInstanceOf(Date); + }); + + it('queries moderated activities', async () => { + // A feed whose test-owned policy removes activities with a blocked word. + const blockedWord = 'frobnicate'; + const blocklist = uniqueId('blocklist'); + await client.createBlockList({ name: blocklist, words: [blockedWord] }); + cleanup.add(() => client.deleteBlockList({ name: blocklist })); + const policy = uniqueId('policy'); + cleanup.add(() => + client.moderation + .deleteConfig({ key: policy }) + .catch((error: unknown) => { + if (!String(error).includes('not found')) throw error; + }), + ); + await retry( + () => + client.moderation.upsertConfig({ + key: policy, + block_list_config: { rules: [{ name: blocklist, action: 'remove' }] }, + }), + { retryIf: (error) => String(error).includes('Blocklist not found') }, + ); + const moderatedFeedId = uniqueId('moderated'); + cleanup.feeds.push(`user:${moderatedFeedId}`); + const moderatedFeed = client.feeds.feed('user', moderatedFeedId); + await moderatedFeed.getOrCreate({ user_id: userId }); + await moderatedFeed.update({ custom: { moderation_policy: policy } }); + + const removedActivityId = await retry(async () => { + const { activity } = await client.feeds.addActivity({ + type: 'post', + feeds: [`user:${moderatedFeedId}`], + text: `you ${blockedWord}`, + user_id: userId, + force_moderation: true, + }); + expect(activity.moderation?.action).toBe('remove'); + return activity.id; + }); + const normalActivityId = await createActivity({ text: 'normal' }); + await queryUntilFound( + { + include_moderated_activities: true, + filter: { id: { $in: [removedActivityId, normalActivityId] } }, + }, + [removedActivityId, normalActivityId], + ); + const without = await client.feeds.queryActivities({ + filter: { id: { $in: [removedActivityId, normalActivityId] } }, + }); + expect(ids(without)).toEqual([normalActivityId]); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/02-querying-activities.md" heading="Querying moderated activities" tab="Node.js" index=1 + // COPY: removedActivityId="activity_123", normalActivityId="activity_456" + const response = await client.feeds.queryActivities({ + include_moderated_activities: true, + filter: { + id: { $in: [removedActivityId, normalActivityId] }, + }, + }); + // #endregion snippet + + expect([...ids(response)].sort()).toEqual( + [removedActivityId, normalActivityId].sort(), + ); + const removed = response.activities.find((a) => a.id === removedActivityId); + expect(removed?.moderation_action).toBe('remove'); + }); + + it('queries activities as a user', async () => { + const john = await createUser('john'); + const privateId = await createActivity({ + text: 'private for john', + visibility: 'private', + }); + await queryUntilFound( + { include_private_activities: true, filter: { id: privateId } }, + [privateId], + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/02-querying-activities.md" heading="`user_id` for server-side requests" tab="Node.js" index=1 + // COPY: john="john" + const response = await client.feeds.queryActivities({ + user_id: john, + filter: { + // your filter here + }, + }); + // #endregion snippet + + // A client-side request: another user's private activity isn't returned. + expect(response.activities.length).toBeGreaterThan(0); + expect(ids(response)).not.toContain(privateId); + }); + + it('combines filters with $and', async () => { + const bothId = await createActivity({ + text: 'both', + filter_tags: ['green', 'orange'], + }); + const greenId = await createActivity({ + text: 'green', + filter_tags: ['green'], + }); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/02-querying-activities.md" heading="`$contains` on custom fields" tab="Node.js" index=1 + // Get all the activities where filter tags contain both "green" and "orange" + const filter = { + $and: [{ filter_tags: ['green'] }, { filter_tags: ['orange'] }], + }; + // #endregion snippet + + const response = await queryUntilFound( + { filter: { $and: [filter, { user_id: userId }] } }, + [bothId], + ); + expect(ids(response)).toEqual([bothId]); + expect(ids(response)).not.toContain(greenId); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/04-activity-feedback.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/04-activity-feedback.test.ts new file mode 100644 index 0000000..fbb3953 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/04-activity-feedback.test.ts @@ -0,0 +1,175 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { ActivityFeedbackResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/05-activities/04-activity-feedback.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +/** Awaits the results of a spied `activityFeedback`. */ +const spiedResults = (spy: { mock: { results: { value: unknown }[] } }) => + Promise.all( + spy.mock.results.map((r) => r.value as Promise), + ); + +describe(DOCS, () => { + let ownerId: string; + + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + /** A new activity by the owner in the owner's public `user` feed. */ + const createActivity = async () => { + const { activity } = await client.feeds.addActivity({ + feeds: [`user:${ownerId}`], + type: 'post', + text: 'feedback me', + user_id: ownerId, + }); + return activity.id; + }; + + /** The activity as `userId` sees it in `queryActivities`, or undefined if not returned. */ + const queryAs = async ( + userId: string, + activityId: string, + hidden?: boolean, + ) => { + const { activities } = await client.feeds.queryActivities({ + user_id: userId, + filter: { + id: activityId, + // `hidden: { $eq: true }` matches nothing (2026-10-16): use the bare value. + ...(hidden === undefined ? {} : { hidden }), + }, + }); + return activities.find((a) => a.id === activityId); + }; + + beforeAll(async () => { + ownerId = await createUser('owner'); + cleanup.feeds.push(`user:${ownerId}`); + await client.feeds.feed('user', ownerId).getOrCreate({ user_id: ownerId }); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('hides and unhides an activity', async () => { + const activityId = await createActivity(); + const jane = await createUser('jane'); + await retry(async () => + expect(await queryAs(jane, activityId)).toBeDefined(), + ); + const feedback = vi.spyOn(client.feeds, 'activityFeedback'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/04-activity-feedback.md" heading="Hide activity" tab="Node.js" index=1 + // COPY: activityId="activity_123", jane="jane" + await client.feeds.activityFeedback({ + activity_id: activityId, + hide: true, + user_id: jane, + }); + // #endregion snippet + + const [hidden] = await spiedResults(feedback); + expect(hidden.activity_id).toBe(activityId); + // Hidden for jane: only returned with a `hidden` filter, flagged `hidden`. + await retry(async () => { + expect(await queryAs(jane, activityId)).toBeUndefined(); + expect((await queryAs(jane, activityId, true))?.hidden).toBe(true); + }); + // Jane's read of the owner's feed leaves it out. + const janeRead = await client.feeds + .feed('user', ownerId) + .getOrCreate({ user_id: jane }); + expect(janeRead.activities.map((a) => a.id)).not.toContain(activityId); + // Not hidden for the owner. + expect(await queryAs(ownerId, activityId)).toBeDefined(); + feedback.mockClear(); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/04-activity-feedback.md" heading="Hide activity" tab="Node.js" index=2 + // COPY: activityId="activity_123", jane="jane" + await client.feeds.activityFeedback({ + activity_id: activityId, + hide: false, + user_id: jane, + }); + // #endregion snippet + + const [unhidden] = await spiedResults(feedback); + expect(unhidden.activity_id).toBe(activityId); + await retry(async () => { + const activity = await queryAs(jane, activityId); + expect(activity).toBeDefined(); + expect(activity?.hidden).toBeFalsy(); + }); + }); + + it('asks for more and less similar content', async () => { + const activityId = await createActivity(); + const jane = await createUser('jane'); + const feedback = vi.spyOn(client.feeds, 'activityFeedback'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/04-activity-feedback.md" heading="Show more/less similar content" tab="Node.js" index=1 + // COPY: activityId="activity_123", jane="jane" + await client.feeds.activityFeedback({ + activity_id: activityId, + show_more: true, + user_id: jane, + }); + // #endregion snippet + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/04-activity-feedback.md" heading="Show more/less similar content" tab="Node.js" index=2 + // COPY: activityId="activity_123", jane="jane" + await client.feeds.activityFeedback({ + activity_id: activityId, + show_less: true, + user_id: jane, + }); + // #endregion snippet + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/04-activity-feedback.md" heading="Show more/less similar content" tab="Node.js" index=3 + // COPY: activityId="activity_123", jane="jane" + // Undo show more feedback + await client.feeds.activityFeedback({ + activity_id: activityId, + show_more: false, + user_id: jane, + }); + + // Undo show less feedback + await client.feeds.activityFeedback({ + activity_id: activityId, + show_less: false, + user_id: jane, + }); + // #endregion snippet + + const results = await spiedResults(feedback); + expect(results.map((r) => r.activity_id)).toEqual([ + activityId, + activityId, + activityId, + activityId, + ]); + // Show more/less doesn't hide anything. + expect(await queryAs(jane, activityId)).toBeDefined(); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/05-activity-metrics.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/05-activity-metrics.test.ts new file mode 100644 index 0000000..b11104c --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/05-activities/05-activity-metrics.test.ts @@ -0,0 +1,323 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { isFeedGroupMissing } from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { waitForAppSetting } from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/05-activities/05-activity-metrics.md'; + +// The docs use both names for the server client. +const client = getServerClient(); +const serverClient = client; +const cleanup = new Cleanup(client); + +/** + * `activity_metrics_config` is absent on the test app. `updateApp({ activity_metrics_config: {} })` + * leaves `{}` behind (DRIFT), and the generated `UpdateAppRequest` has no `| null`, so the + * restore sends `null` with a raw request, which removes the setting again. + */ +const restoreMetricsConfig = async (original?: Record) => { + await client.apiClient.sendRequest( + 'PATCH', + '/api/v2/app', + undefined, + undefined, + { activity_metrics_config: original ?? null }, + ); + await waitForAppSetting(client, 'activity_metrics_config', original); +}; + +describe(DOCS, () => { + let authorId: string; + let originalConfig: Record | undefined; + + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + /** A new activity in the author's user feed; returns its id. */ + const createActivity = async (text = 'setup') => { + const { activity } = await client.feeds.addActivity({ + feeds: [`user:${authorId}`], + type: 'post', + text, + user_id: authorId, + }); + return activity.id; + }; + + beforeAll(async () => { + originalConfig = (await client.getApp()).app.activity_metrics_config; + authorId = await createUser('author'); + cleanup.feeds.push(`user:${authorId}`); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('tracks metrics', async () => { + const activityId = await createActivity(); + const userId = await createUser('viewer'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/05-activity-metrics.md" heading="Tracking Metrics" tab="Node.js" index=1 + // COPY: activityId="activity_123", userId="user_456" + const response = await client.feeds.trackActivityMetrics({ + events: [ + { activity_id: activityId, metric: 'views' }, + { activity_id: activityId, metric: 'clicks', delta: 2 }, + ], + user_id: userId, + }); + + for (const result of response.results) { + console.log( + `${result.activity_id}/${result.metric}: allowed=${result.allowed}`, + ); + } + // #endregion snippet + + expect(response.results).toMatchObject([ + { activity_id: activityId, metric: 'views', allowed: true }, + { activity_id: activityId, metric: 'clicks', allowed: true }, + ]); + expect(log.mock.calls).toEqual([ + [`${activityId}/views: allowed=true`], + [`${activityId}/clicks: allowed=true`], + ]); + }); + + it('tracks metrics for several activities in one request', async () => { + const activity1 = await createActivity('one'); + const activity2 = await createActivity('two'); + const activity3 = await createActivity('three'); + const userId = await createUser('viewer'); + const track = vi.spyOn(client.feeds, 'trackActivityMetrics'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/05-activity-metrics.md" heading="Batching" tab="Node.js" index=1 + // COPY: activity1="activity_1", activity2="activity_2", activity3="activity_3", userId="user_456" + // Track metrics for multiple activities in one request (server-side) + const response = await client.feeds.trackActivityMetrics({ + events: [ + { activity_id: activity1, metric: 'views' }, + { activity_id: activity1, metric: 'clicks' }, + { activity_id: activity2, metric: 'views' }, + { activity_id: activity3, metric: 'impressions', delta: 5 }, + ], + user_id: userId, + }); + // #endregion snippet + + expect(track).toHaveBeenCalledTimes(1); + // Each event gets its own result, in request order. + expect(response.results).toMatchObject([ + { activity_id: activity1, metric: 'views', allowed: true }, + { activity_id: activity1, metric: 'clicks', allowed: true }, + { activity_id: activity2, metric: 'views', allowed: true }, + { activity_id: activity3, metric: 'impressions', allowed: true }, + ]); + }); + + it('checks the allowed field', async () => { + const activityId = await createActivity(); + const userId = await createUser('viewer'); + // Use up this user's hourly "views" limit (10) on the activity, so the + // region's event is rate limited. The absolute delta counts against it. + const setup = await client.feeds.trackActivityMetrics({ + events: [{ activity_id: activityId, metric: 'views', delta: 10 }], + user_id: userId, + }); + expect(setup.results[0].allowed).toBe(true); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/05-activity-metrics.md" heading="Rate Limiting" tab="Node.js" index=1 + // COPY: activityId="activity_123", userId="user_456" + const response = await client.feeds.trackActivityMetrics({ + events: [{ activity_id: activityId, metric: 'views' }], + user_id: userId, + }); + + for (const result of response.results) { + if (!result.allowed) { + console.log(`Rate limited: ${result.activity_id}/${result.metric}`); + } + } + // #endregion snippet + + // A rate-limited event is not an error, just `allowed: false`. + expect(response.results).toMatchObject([ + { activity_id: activityId, metric: 'views', allowed: false }, + ]); + expect(log.mock.calls).toEqual([[`Rate limited: ${activityId}/views`]]); + }); + + it('reads metrics from an activity', async () => { + const activityId = await createActivity(); + const userId = await createUser('viewer'); + await client.feeds.trackActivityMetrics({ + events: [{ activity_id: activityId, metric: 'views' }], + user_id: userId, + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/05-activity-metrics.md" heading="Reading Metrics" tab="Node.js" index=1 + // COPY: activityId="activity_123" + // metrics is available on any activity response + const { activity } = await client.feeds.getActivity({ id: activityId }); + console.log(activity.metrics); + // { views: 42, clicks: 15, impressions: 200 } + // #endregion snippet + + expect(activity.id).toBe(activityId); + expect(log).toHaveBeenCalledWith(activity.metrics); + // Metrics are synced to the database every 5 minutes, so the view tracked + // above isn't there yet: `metrics` is absent until the first sync. + expect( + activity.metrics === undefined || typeof activity.metrics === 'object', + ).toBe(true); + }); + + it('ranks a feed group by metrics', async () => { + const trendingId = uniqueId('trending'); + cleanup.feedGroups.push(trendingId); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/05-activity-metrics.md" heading="Using Metrics in Ranking" tab="Node.js" index=1 + // COPY: trendingId="trending" + // Create a feed group ranked by engagement metrics + const response = await serverClient.feeds.createFeedGroup({ + id: trendingId, + ranking: { + type: 'expression', + score: 'metrics.views * 2 + metrics.clicks * 3', + defaults: { + metrics: { + views: 0, + clicks: 0, + }, + }, + }, + activity_selectors: [{ type: 'following' }], + }); + // #endregion snippet + + expect(response.feed_group).toMatchObject({ + id: trendingId, + ranking: { + type: 'expression', + score: 'metrics.views * 2 + metrics.clicks * 3', + }, + }); + + // A feed in the group that follows the author ranks the author's + // activities (none has metrics yet, so the defaults apply). + const activityId = await createActivity('ranked'); + const readerId = await createUser('reader'); + cleanup.feeds.push(`${trendingId}:${readerId}`); + const feed = client.feeds.feed(trendingId, readerId); + await retry(() => feed.getOrCreate({ user_id: readerId }), { + retryIf: isFeedGroupMissing, + }); + await retry( + () => + client.feeds.follow({ + source: feed.feed, + target: `user:${authorId}`, + }), + { retryIf: isFeedGroupMissing }, + ); + const read = await retry(async () => { + const result = await feed.getOrCreate({ user_id: readerId }); + if (!result.activities.some((a) => a.id === activityId)) { + throw new Error('activity not in the ranked feed yet'); + } + return result; + }); + expect(read.activities.map((a) => a.id)).toContain(activityId); + }); + + it('configures custom metrics', async () => { + cleanup.add(() => restoreMetricsConfig(originalConfig)); + const activityId = await createActivity(); + const userId = await createUser('viewer'); + const updateApp = vi.spyOn(client, 'updateApp'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/05-activity-metrics.md" heading="Custom Metrics Configuration" tab="Node.js" index=1 + // Add custom metrics and override the default "views" limit (server-side) + await client.updateApp({ + activity_metrics_config: { + shares: 5, // new custom metric: 5 per hour per user per activity + saves: 10, // new custom metric: 10 per hour + views: 20, // override default views limit from 10 to 20 + impressions: 0, // disable the default impressions metric + }, + }); + // #endregion snippet + + const custom = { shares: 5, saves: 10, views: 20, impressions: 0 }; + expect(updateApp).toHaveBeenCalledTimes(1); + await waitForAppSetting(client, 'activity_metrics_config', custom); + + { + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/05-activity-metrics.md" heading="Custom Metrics Configuration" tab="Node.js" index=2 + const app = await client.getApp(); + console.log(app.app.activity_metrics_config); + // { shares: 5, saves: 10, views: 20, impressions: 0 } + // #endregion snippet + + expect(app.app.activity_metrics_config).toEqual(custom); + expect(log).toHaveBeenCalledWith(custom); + } + + // The new metric counts, the disabled default is rejected. + const tracked = await retry(async () => { + const result = await client.feeds.trackActivityMetrics({ + events: [ + { activity_id: activityId, metric: 'shares' }, + { activity_id: activityId, metric: 'impressions' }, + ], + user_id: userId, + }); + if (!result.results[0].allowed) throw new Error('config not applied'); + return result; + }); + expect(tracked.results).toMatchObject([ + { metric: 'shares', allowed: true }, + { metric: 'impressions', allowed: false }, + ]); + expect(tracked.results[1].error).toContain('impressions'); + + // #region snippet docs="activity-feeds/v3-latest/_default/05-activities/05-activity-metrics.md" heading="Custom Metrics Configuration" tab="Node.js" index=3 + await client.updateApp({ activity_metrics_config: {} }); + // #endregion snippet + + expect(updateApp).toHaveBeenCalledTimes(2); + // The docs say an empty config clears every custom override, but + // `updateApp` merges `activity_metrics_config` into the stored one, so `{}` + // changes nothing (2026-10-16). Only a raw `null` (not allowed by the + // generated types) removes it, which is what the restore below does. + await waitForAppSetting(client, 'activity_metrics_config', custom); + const after = await client.feeds.trackActivityMetrics({ + events: [{ activity_id: activityId, metric: 'impressions' }], + user_id: userId, + }); + expect(after.results).toMatchObject([ + { metric: 'impressions', allowed: false }, + ]); + + await restoreMetricsConfig(originalConfig); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/01-reactions.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/01-reactions.test.ts new file mode 100644 index 0000000..df584b4 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/01-reactions.test.ts @@ -0,0 +1,352 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + ActivityResponse, + AddCommentReactionResponse, + CommentResponse, + QueryActivityReactionsResponse, + QueryCommentReactionsResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'activity-feeds/v3-latest/_default/06-features/01-reactions.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +describe(DOCS, () => { + let authorId: string; + + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + /** A new activity in the author's user feed. */ + const createActivity = async (text = 'setup') => { + const { activity } = await client.feeds.addActivity({ + feeds: [`user:${authorId}`], + type: 'post', + text, + user_id: authorId, + }); + return activity; + }; + + /** A new comment by the author on `activityId`. */ + const createComment = async (activityId: string, text = 'setup') => { + const { comment } = await client.feeds.addComment({ + object_id: activityId, + object_type: 'activity', + comment: text, + user_id: authorId, + }); + return comment; + }; + + beforeAll(async () => { + authorId = await createUser('author'); + cleanup.feeds.push(`user:${authorId}`); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('adds and deletes reactions', async () => { + const activity = await createActivity(); + const comment = await createComment(activity.id); + const activityId = activity.id; + const commentId = comment.id; + const reactingUserId = await createUser('reacting'); + const addCommentReaction = vi.spyOn(client.feeds, 'addCommentReaction'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/01-reactions.md" heading="Overview" tab="Node.js" index=1 + // COPY: activityId="activity_123", commentId="comment_456", reactingUserId="reacting_user_id" + // Add a reaction to an activity + const addResponse = await client.feeds.addActivityReaction({ + activity_id: activityId, + type: 'like', + custom: { + emoji: '❤️', + }, + user_id: reactingUserId, + // Optionally override existing reaction + enforce_unique: true, + }); + + console.log(addResponse.reaction); + + // Adding a reaction without triggering push notifications + await client.feeds.addActivityReaction({ + activity_id: activityId, + type: 'like', + custom: { + emoji: '❤️', + }, + user_id: reactingUserId, + skip_push: true, + }); + + // Add a reaction to a comment + await client.feeds.addCommentReaction({ + id: commentId, + type: 'like', + custom: { + emoji: '👍', + }, + user_id: reactingUserId, + // Optionally override existing reaction + enforce_unique: true, + }); + + // Adding a comment reaction without triggering push notifications + await client.feeds.addCommentReaction({ + id: commentId, + type: 'like', + custom: { + emoji: '👍', + }, + user_id: reactingUserId, + skip_push: true, + }); + + const deleteResponse = await client.feeds.deleteActivityReaction({ + activity_id: activityId, + type: 'like', + user_id: reactingUserId, + }); + + console.log(deleteResponse.reaction); + // #endregion snippet + + expect(addResponse.reaction).toMatchObject({ + activity_id: activityId, + type: 'like', + custom: { emoji: '❤️' }, + user: { id: reactingUserId }, + }); + expect(deleteResponse.reaction).toMatchObject({ + activity_id: activityId, + type: 'like', + user: { id: reactingUserId }, + }); + expect(log.mock.calls).toEqual([ + [addResponse.reaction], + [deleteResponse.reaction], + ]); + const commentReactions = await Promise.all( + addCommentReaction.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(commentReactions.map((r) => r.reaction)).toMatchObject([ + { comment_id: commentId, type: 'like', custom: { emoji: '👍' } }, + { comment_id: commentId, type: 'like', custom: { emoji: '👍' } }, + ]); + + // The activity reaction is gone, the comment reaction stays (one per type). + const { activity: stored } = await client.feeds.getActivity({ + id: activityId, + }); + expect(stored.reaction_groups.like).toBeUndefined(); + const { reactions } = await client.feeds.queryCommentReactions({ + id: commentId, + }); + expect(reactions).toHaveLength(1); + }); + + it('reads reactions from the feed', async () => { + const saraId = await createUser('sara'); + cleanup.feeds.push(`user:${saraId}`); + const { activity } = await client.feeds.addActivity({ + feeds: [`user:${saraId}`], + type: 'post', + text: 'hello', + user_id: saraId, + }); + await client.feeds.addActivityReaction({ + activity_id: activity.id, + type: 'like', + user_id: saraId, + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/01-reactions.md" heading="Reaction write outcome" tab="Node.js" index=1 + // COPY: saraId="sara" + const feed = client.feeds.feed('user', saraId); + const response = await feed.getOrCreate({ user_id: saraId }); + + console.log(response.activities[0].own_reactions); + console.log(response.activities[0].latest_reactions); + console.log(response.activities[0].reaction_groups); + // #endregion snippet + + const [first] = response.activities; + expect(first.id).toBe(activity.id); + expect(first.own_reactions).toMatchObject([ + { type: 'like', user: { id: saraId } }, + ]); + expect(first.latest_reactions).toMatchObject([ + { type: 'like', user: { id: saraId } }, + ]); + expect(first.reaction_groups.like).toMatchObject({ count: 1 }); + expect(log.mock.calls).toEqual([ + [first.own_reactions], + [first.latest_reactions], + [first.reaction_groups], + ]); + }); + + it('queries reactions', async () => { + const activity: ActivityResponse = await createActivity(); + const comment: CommentResponse = await createComment(activity.id); + const userId = await createUser('reacting'); + for (const type of ['like', 'love']) { + await client.feeds.addActivityReaction({ + activity_id: activity.id, + type, + user_id: userId, + }); + await client.feeds.addCommentReaction({ + id: comment.id, + type, + user_id: userId, + }); + } + const queryActivityReactions = vi.spyOn( + client.feeds, + 'queryActivityReactions', + ); + const queryCommentReactions = vi.spyOn( + client.feeds, + 'queryCommentReactions', + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/01-reactions.md" heading="Querying Reactions" tab="Node.js" index=1 + await client.feeds.queryActivityReactions({ + activity_id: activity.id, + // Provide optional filters + filter: { + reaction_type: 'like', + }, + }); + + await client.feeds.queryCommentReactions({ + id: comment.id, + // Provide optional filters + + filter: { + reaction_type: 'like', + }, + }); + // #endregion snippet + + const activityReactions = await (queryActivityReactions.mock.results[0] + .value as Promise); + const commentReactions = await (queryCommentReactions.mock.results[0] + .value as Promise); + // The filter keeps only the "like" reactions. + expect(activityReactions.reactions).toMatchObject([ + { type: 'like', activity_id: activity.id }, + ]); + expect(commentReactions.reactions).toMatchObject([ + { type: 'like', comment_id: comment.id }, + ]); + }); + + it('batch queries activity reactions', async () => { + const activities = [await createActivity('a'), await createActivity('b')]; + const saraId = await createUser('sara'); + const otherId = await createUser('other'); + for (const activity of activities) { + for (const [user_id, type] of [ + [saraId, 'like'], + [saraId, 'love'], + [otherId, 'like'], + ]) { + await client.feeds.addActivityReaction({ + activity_id: activity.id, + type, + user_id, + }); + } + } + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/01-reactions.md" heading="Batch query activity reactions" tab="Node.js" index=1 + // COPY: saraId="sara" + // Server-side: pass user_id to choose whose reactions to fetch. + const response = await client.feeds.batchQueryActivityReactions({ + activity_ids: activities.map((activity) => activity.id), + user_id: saraId, + filter: { reaction_type: 'like' }, + }); + + console.log(response.reactions); + // #endregion snippet + + // Only Sara's "like" reactions, on both activities. + expect(log).toHaveBeenCalledWith(response.reactions); + const flattened = response.reactions; + expect(flattened).toHaveLength(2); + for (const reaction of flattened) { + expect(reaction).toMatchObject({ type: 'like', user: { id: saraId } }); + } + }); + + it('batch queries comment reactions', async () => { + const activity = await createActivity(); + const comments = [ + await createComment(activity.id, 'a'), + await createComment(activity.id, 'b'), + ]; + const saraId = await createUser('sara'); + const otherId = await createUser('other'); + for (const comment of comments) { + for (const [user_id, type] of [ + [saraId, 'like'], + [saraId, 'love'], + [otherId, 'like'], + ]) { + await client.feeds.addCommentReaction({ + id: comment.id, + type, + user_id, + }); + } + } + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/01-reactions.md" heading="Batch query comment reactions" tab="Node.js" index=1 + // COPY: saraId="sara" + // Server-side: pass user_id to choose whose reactions to fetch. + const response = await client.feeds.batchQueryCommentReactions({ + comment_ids: comments.map((comment) => comment.id), + user_id: saraId, + filter: { reaction_type: 'like' }, + }); + + console.log(response.reactions); + // #endregion snippet + + expect(log).toHaveBeenCalledWith(response.reactions); + const flattened = response.reactions; + expect(flattened).toHaveLength(2); + for (const reaction of flattened) { + expect(reaction).toMatchObject({ type: 'like', user: { id: saraId } }); + } + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/02-bookmarks.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/02-bookmarks.test.ts new file mode 100644 index 0000000..80ab13f --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/02-bookmarks.test.ts @@ -0,0 +1,313 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'activity-feeds/v3-latest/_default/06-features/02-bookmarks.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +describe(DOCS, () => { + /** A `uniqueId` user with its own user feed (bookmarks go away with the user). */ + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + cleanup.feeds.push(`user:${id}`); + await client.upsertUsers([{ id }]); + return id; + }; + + /** A new activity in `userId`'s user feed. */ + const createActivity = async (userId: string, text = 'setup') => { + const { activity } = await client.feeds.addActivity({ + feeds: [`user:${userId}`], + type: 'post', + text, + user_id: userId, + }); + return activity; + }; + + /** A folder of `userId`, created by bookmarking a setup activity into it. */ + const createFolder = async (userId: string, name: string) => { + const activity = await createActivity(userId); + const { bookmark } = await client.feeds.addBookmark({ + activity_id: activity.id, + user_id: userId, + new_folder: { name }, + }); + const id = bookmark.folder?.id; + if (!id) throw new Error('setup folder has no id'); + return id; + }; + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('adds and updates bookmarks', async () => { + const userId = await createUser('bookmarker'); + const activityId = (await createActivity(userId)).id; + const folderId = await createFolder(userId, 'existing'); + const oldFolderId = folderId; + const newFolderId = await createFolder(userId, 'target'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/02-bookmarks.md" heading="Adding Bookmarks" tab="Node.js" index=1 + // COPY: activityId="activity_123", userId="", folderId="folder_456", oldFolderId="", newFolderId="" + // Adding a bookmark to a new folder + const bookmark = await client.feeds.addBookmark({ + activity_id: activityId, + user_id: userId, + }); + + // Adding to an existing folder + const bookmarkWithFolder = await client.feeds.addBookmark({ + activity_id: activityId, + folder_id: folderId, + user_id: userId, + }); + + // Update a bookmark (without a folder initially) - add custom data and move it to a new folder + const updatedBookmark = await client.feeds.updateBookmark({ + activity_id: activityId, + new_folder: { + name: 'New folder name', + custom: { + icon: '📂', + }, + }, + custom: { + color: 'blue', + }, + user_id: userId, + }); + + // Update a bookmark - move it from one existing folder to another existing folder + const movedBookmark = await client.feeds.updateBookmark({ + activity_id: activityId, + folder_id: oldFolderId, + new_folder_id: newFolderId, + user_id: userId, + }); + // #endregion snippet + + expect(bookmark.bookmark).toMatchObject({ activity_id: activityId }); + expect(bookmark.bookmark.folder).toBeUndefined(); + expect(bookmarkWithFolder.bookmark).toMatchObject({ + activity_id: activityId, + folder: { id: folderId }, + }); + expect(updatedBookmark.bookmark).toMatchObject({ + activity_id: activityId, + folder: { name: 'New folder name', custom: { icon: '📂' } }, + custom: { color: 'blue' }, + }); + expect(movedBookmark.bookmark).toMatchObject({ + activity_id: activityId, + folder: { id: newFolderId }, + }); + }); + + it('removes a bookmark and reads bookmarks from the feed', async () => { + const saraId = await createUser('sara'); + const testUser2 = { id: await createUser('test-user-2') }; + const activityId = (await createActivity(saraId)).id; + for (const user_id of [saraId, testUser2.id]) { + await client.feeds.addBookmark({ activity_id: activityId, user_id }); + } + const serverClient = client; + const feed = client.feeds.feed('user', saraId); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/02-bookmarks.md" heading="Removing Bookmarks" tab="Node.js" index=1 + // COPY: saraId="sara" + // Removing a bookmark + await serverClient.feeds.deleteBookmark({ + activity_id: activityId, + user_id: testUser2.id, + }); + + // When you read a feed we include the bookmark + const response = await feed.getOrCreate({ user_id: saraId }); + console.log(response.activities[0].own_bookmarks); + console.log(response.activities[0].bookmark_count); + // #endregion snippet + + const [first] = response.activities; + expect(first.id).toBe(activityId); + // Only Sara's bookmark is left. + expect(first.bookmark_count).toBe(1); + expect(first.own_bookmarks).toMatchObject([{ user: { id: saraId } }]); + expect(log.mock.calls).toEqual([ + [first.own_bookmarks], + [first.bookmark_count], + ]); + }); + + it('queries bookmarks', async () => { + const userId = await createUser('bookmarker'); + const folderId = await createFolder(userId, 'folder'); + const activities = [ + await createActivity(userId, 'a'), + await createActivity(userId, 'b'), + ]; + for (const activity of activities) { + await client.feeds.addBookmark({ + activity_id: activity.id, + user_id: userId, + }); + } + const activityId = activities[0].id; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/02-bookmarks.md" heading="Querying Bookmarks" tab="Node.js" index=1 + // COPY: userId="", activityId="activity_123", folderId="folder_456" + // Query bookmarks + const firstPage = await client.feeds.queryBookmarks({ + filter: { + user_id: userId, + }, + limit: 2, + }); + + // Get next page + const secondPage = await client.feeds.queryBookmarks({ + filter: { + user_id: userId, + }, + limit: 2, + next: firstPage.next, + }); + + // Query by activity ID + const response = await client.feeds.queryBookmarks({ + filter: { + user_id: userId, + activity_id: activityId, + }, + }); + + // Query by folder ID + const folderResponse = await client.feeds.queryBookmarks({ + filter: { + user_id: userId, + folder_id: folderId, + }, + }); + // #endregion snippet + + // Three bookmarks: one in the folder, two without a folder. + expect(firstPage.bookmarks).toHaveLength(2); + expect(secondPage.bookmarks).toHaveLength(1); + expect( + [...firstPage.bookmarks, ...secondPage.bookmarks].map( + (b) => b.activity_id, + ), + ).toEqual(expect.arrayContaining(activities.map((a) => a.id))); + expect(response.bookmarks.map((b) => b.activity_id)).toEqual([activityId]); + expect(folderResponse.bookmarks).toMatchObject([ + { folder: { id: folderId } }, + ]); + }); + + it('queries bookmark folders', async () => { + const userId = await createUser('bookmarker'); + for (const name of ['project a', 'project b', 'project c', 'other']) { + await createFolder(userId, name); + } + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/02-bookmarks.md" heading="Querying Bookmark Folders" tab="Node.js" index=1 + // COPY: userId="" + // Query bookmark folders + const firstPage = await client.feeds.queryBookmarkFolders({ + filter: { + user_id: userId, + }, + limit: 2, + }); + + // Get next page + const secondPage = await client.feeds.queryBookmarkFolders({ + filter: { + user_id: userId, + }, + limit: 2, + next: firstPage.next, + }); + + const response = await client.feeds.queryBookmarkFolders({ + filter: { + user_id: userId, + folder_name: { + $contains: 'project', + }, + }, + limit: 2, + }); + // #endregion snippet + + // Newest first: every folder, then the first two "project" folders. + expect( + [...firstPage.bookmark_folders, ...secondPage.bookmark_folders].map( + (f) => f.name, + ), + ).toEqual(['other', 'project c', 'project b', 'project a']); + expect(response.bookmark_folders.map((f) => f.name)).toEqual([ + 'project c', + 'project b', + ]); + }); + + it('manages bookmark folders', async () => { + const userId = await createUser('bookmarker'); + const activity = await createActivity(userId); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/02-bookmarks.md" heading="Delete bookmark folder" tab="Node.js" index=1 + // COPY: userId="" + const bookmark = ( + await client.feeds.addBookmark({ + activity_id: activity.id, + user_id: userId, + new_folder: { + name: 'Breakfast recipes', + custom: { + icon: '🍳', + }, + }, + }) + ).bookmark; + + const updatedFolder = ( + await client.feeds.updateBookmarkFolder({ + folder_id: bookmark.folder?.id ?? '', + name: 'Sweet Breakfast Recipes', + custom: { + icon: '🥞', + }, + }) + ).bookmark_folder; + + await client.feeds.deleteBookmarkFolder({ + folder_id: updatedFolder.id, + }); + // #endregion snippet + + expect(bookmark.folder).toMatchObject({ + name: 'Breakfast recipes', + custom: { icon: '🍳' }, + }); + expect(updatedFolder).toMatchObject({ + id: bookmark.folder?.id, + name: 'Sweet Breakfast Recipes', + custom: { icon: '🥞' }, + }); + // Deleting the folder deletes its bookmarks too. + const { bookmarks } = await client.feeds.queryBookmarks({ + filter: { user_id: userId }, + }); + expect(bookmarks).toEqual([]); + const { bookmark_folders } = await client.feeds.queryBookmarkFolders({ + filter: { user_id: userId }, + }); + expect(bookmark_folders).toEqual([]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/03-comments.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/03-comments.test.ts new file mode 100644 index 0000000..c8f43d6 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/03-comments.test.ts @@ -0,0 +1,433 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + AddCommentResponse, + DeleteCommentReactionResponse, + QueryCommentsResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'activity-feeds/v3-latest/_default/06-features/03-comments.md'; + +const client = getServerClient(); +// Some fences of the page call the server client `serverClient`. +const serverClient = client; +const cleanup = new Cleanup(client); + +describe(DOCS, () => { + let userId: string; + + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + cleanup.feeds.push(`user:${id}`); + await client.upsertUsers([{ id }]); + return id; + }; + + /** A new activity in `authorId`'s user feed. */ + const createActivity = async (authorId = userId) => { + const { activity } = await client.feeds.addActivity({ + feeds: [`user:${authorId}`], + type: 'post', + text: 'setup', + user_id: authorId, + }); + return activity.id; + }; + + /** A new comment by the user on an activity, or a reply to a comment. */ + const createComment = async ( + target: { object_id: string } | { parent_id: string }, + extra: { id?: string; comment?: string; user_id?: string } = {}, + ) => { + const { comment } = await client.feeds.addComment({ + ...('object_id' in target + ? { object_id: target.object_id, object_type: 'activity' } + : target), + comment: 'setup', + user_id: userId, + ...extra, + }); + return comment; + }; + + beforeAll(async () => { + userId = await createUser('user'); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('adds comments', async () => { + const activityId = await createActivity(); + const parentCommentId = (await createComment({ object_id: activityId })).id; + const commentId = uniqueId('comment'); + const addComment = vi.spyOn(serverClient.feeds, 'addComment'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Adding Comments" tab="Node.js" index=1 + // COPY: commentId="comment_123", activityId="activity_123", userId="", parentCommentId="comment_456" + // Adding a comment to an activity + await serverClient.feeds.addComment({ + id: commentId, // id is optional, but must be unique. will be auto-generated if not provided + comment: 'So great!', + object_id: activityId, + object_type: 'activity', + user_id: userId, + custom: { + sentiment: 'positive', + }, + }); + + // Adding a reply to a comment + await serverClient.feeds.addComment({ + comment: 'I agree!', + parent_id: parentCommentId, + user_id: userId, + }); + + // Adding a comment without triggering push notifications + await serverClient.feeds.addComment({ + comment: 'Silent comment', + object_id: activityId, + object_type: 'activity', + user_id: userId, + skip_push: true, + }); + + // Adding a comment with attachments + await serverClient.feeds.addComment({ + comment: 'Check out this image!', + object_id: activityId, + object_type: 'activity', + user_id: userId, + attachments: [ + { + type: 'image', + image_url: 'https://example.com/image.png', + custom: {}, + }, + ], + }); + // #endregion snippet + + const [comment, reply, silent, withImage] = await Promise.all( + addComment.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(comment.comment).toMatchObject({ + id: commentId, + text: 'So great!', + object_id: activityId, + custom: { sentiment: 'positive' }, + user: { id: userId }, + }); + expect(reply.comment).toMatchObject({ + text: 'I agree!', + parent_id: parentCommentId, + object_id: activityId, + }); + expect(silent.comment.text).toBe('Silent comment'); + expect(withImage.comment.attachments).toMatchObject([ + { type: 'image', image_url: 'https://example.com/image.png' }, + ]); + }); + + it('updates a comment', async () => { + const activityId = await createActivity(); + const commentId = (await createComment({ object_id: activityId })).id; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Updating Comments" tab="Node.js" index=1 + // COPY: commentId="comment_123", userId="" + // Update a comment + const response = await client.feeds.updateComment({ + id: commentId, + comment: 'Updated comment', + user_id: userId, + }); + console.log(response.comment.edited_at); + // #endregion snippet + + expect(response.comment.text).toBe('Updated comment'); + expect(response.comment.edited_at).toBeInstanceOf(Date); + expect(log).toHaveBeenCalledWith(response.comment.edited_at); + }); + + it('removes a comment', async () => { + const activityId = await createActivity(); + const commentId = (await createComment({ object_id: activityId })).id; + const deleteComment = vi.spyOn(client.feeds, 'deleteComment'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Removing Comments" tab="Node.js" index=1 + // COPY: commentId="comment_123" + await client.feeds.deleteComment({ + id: commentId, + }); + // #endregion snippet + + await deleteComment.mock.results[0].value; + const { comments } = await client.feeds.getComments({ + object_type: 'activity', + object_id: activityId, + }); + expect(comments.map((c) => c.id)).not.toContain(commentId); + }); + + it('reads comments', async () => { + const readActivityId = await createActivity(); + const readCommentId = (await createComment({ object_id: readActivityId })) + .id; + const feed = client.feeds.feed('user', userId); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Reading Comments" tab="Node.js" index=1 + // COPY: userId="", readActivityId="123", readCommentId="123" + const response = await feed.getOrCreate({ + user_id: userId, + }); + + console.log(response.activities[0].comments); + + // Or using the getComments endpoint + const comments = await client.feeds.getComments({ + object_type: 'activity', + object_id: readActivityId, + limit: 10, + sort: 'best', + }); + + // Reading a single comment + const comment = await client.feeds.getComment({ id: readCommentId }); + // #endregion snippet + + const latest = response.activities[0]; + expect(latest.id).toBe(readActivityId); + expect(latest.comments.map((c) => c.id)).toEqual([readCommentId]); + expect(log).toHaveBeenCalledWith(latest.comments); + expect(comments.comments.map((c) => c.id)).toEqual([readCommentId]); + expect(comment.comment).toMatchObject({ + id: readCommentId, + object_id: readActivityId, + }); + }); + + it('reads comment counts', async () => { + const activityId = await createActivity(); + const parent = await createComment({ object_id: activityId }); + await createComment({ parent_id: parent.id }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Comment counts" tab="Node.js" index=1 + // COPY: activityId="activity_123" + // Top-level count on a single activity + const { activity } = await client.feeds.getActivity({ + id: activityId, + include_top_level_comment_count: true, + }); + console.log(activity.comment_count, activity.top_level_comment_count); + + // Both counts on the first page of comments + const response = await client.feeds.getComments({ + object_type: 'activity', + object_id: activityId, + limit: 20, + include_top_level_comment_count: true, + }); + console.log(response.comment_count, response.top_level_comment_count); + // #endregion snippet + + // One top-level comment plus one reply. + expect(activity.comment_count).toBe(2); + expect(activity.top_level_comment_count).toBe(1); + expect(response.comment_count).toBe(2); + expect(response.top_level_comment_count).toBe(1); + expect(log.mock.calls).toEqual([ + [2, 1], + [2, 1], + ]); + }); + + it('searches comments', async () => { + const janeId = await createUser('jane'); + const jane = { id: janeId }; + const activityId = await createActivity(janeId); + const oatComment = await createComment( + { object_id: activityId }, + { comment: `I love oat milk ${janeId}`, user_id: janeId }, + ); + // Wait until the search index has the comment, so the region runs once. + await retry(async () => { + const { comments } = await client.feeds.queryComments({ + filter: { comment_text: { $q: 'oat' }, user_id: janeId }, + }); + expect(comments.map((c) => c.id)).toContain(oatComment.id); + }); + const queryComments = vi.spyOn(client.feeds, 'queryComments'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Search for Comments" tab="Node.js" index=1 + // Search in comment texts + await client.feeds.queryComments({ + filter: { comment_text: { $q: 'oat' } }, + }); + + // Comments of a user + await client.feeds.queryComments({ + filter: { + user_id: jane.id, + }, + limit: 20, + }); + // #endregion snippet + + const [byText, byUser] = await Promise.all( + queryComments.mock.results.map( + (r) => r.value as Promise, + ), + ); + // The text search matches comments of the whole app: ours is among them. + expect(byText.comments.every((c) => /oat/i.test(c.text ?? ''))).toBe(true); + expect(byUser.comments.map((c) => c.id)).toEqual([oatComment.id]); + }); + + it('adds and deletes a comment reaction', async () => { + const activityId = await createActivity(); + const commentId = (await createComment({ object_id: activityId })).id; + const deleteCommentReaction = vi.spyOn( + client.feeds, + 'deleteCommentReaction', + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Comment Reactions" tab="Node.js" index=1 + // COPY: commentId="comment_123", userId="" + // Add a reaction to a comment + const response = await client.feeds.addCommentReaction({ + id: commentId, + type: 'like', + user_id: userId, + // Optionally override existing reaction + enforce_unique: true, + }); + + await client.feeds.deleteCommentReaction({ + id: commentId, + type: 'like', + user_id: userId, + }); + // #endregion snippet + + expect(response.reaction).toMatchObject({ + comment_id: commentId, + type: 'like', + user: { id: userId }, + }); + const deleted = await (deleteCommentReaction.mock.results[0] + .value as Promise); + expect(deleted.reaction).toMatchObject({ type: 'like' }); + const { reactions } = await client.feeds.queryCommentReactions({ + id: commentId, + }); + expect(reactions).toEqual([]); + }); + + it('reads threaded comments', async () => { + const activityId = await createActivity(); + const parent = await createComment({ object_id: activityId }); + const reply = await createComment({ parent_id: parent.id }); + const nested = await createComment({ parent_id: reply.id }); + const parentId = parent.id; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Comment Threading" tab="Node.js" index=1 + // COPY: activityId="activity_123", parentId="" + const response = await client.feeds.getComments({ + object_id: activityId, + object_type: 'activity', + // Depth of the threaded comments + depth: 3, + limit: 20, + }); + + // Get replies of a specific parent comment + const repliesResponse = await client.feeds.getCommentReplies({ + id: parentId, + }); + // #endregion snippet + + // The top level holds the parent, with its replies nested below it. + expect(response.comments.map((c) => c.id)).toEqual([parent.id]); + const [replyThread] = response.comments[0].replies ?? []; + expect(replyThread?.id).toBe(reply.id); + expect(replyThread?.replies?.map((c) => c.id)).toEqual([nested.id]); + expect(repliesResponse.comments.map((c) => c.id)).toEqual([reply.id]); + }); + + it('restricts who can add comments', async () => { + const userFeed = `user:${userId}`; + const strangerId = await createUser('stranger'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Set who can add comments" tab="Node.js" index=1 + // COPY: userFeed="user:1", userId="" + const response = await client.feeds.addActivity({ + feeds: [userFeed], + type: 'post', + text: 'apple stock will go up', + user_id: userId, + restrict_replies: 'people_i_follow', // Options: "everyone", "people_i_follow", "nobody" + }); + // #endregion snippet + + expect(response.activity.restrict_replies).toBe('people_i_follow'); + // The author doesn't follow the stranger, so the stranger can't comment. + const error: unknown = await createComment( + { object_id: response.activity.id }, + { user_id: strangerId }, + ).catch((e: unknown) => e); + expect(String(error)).toContain( + 'only users followed by the activity author can reply', + ); + // The author always can. + const own = await createComment({ object_id: response.activity.id }); + expect(own.user.id).toBe(userId); + }); + + it('adds comments in batch', async () => { + const activityId = await createActivity(); + const commentId = uniqueId('comment'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/03-comments.md" heading="Add comments in batch" tab="Node.js" index=1 + // COPY: commentId="comment_123", activityId="activity_123", userId="" + await serverClient.feeds.addCommentsBatch({ + comments: [ + { + id: commentId, // id is optional, but must be unique. will be auto-generated if not provided + comment: 'So great!', + object_id: activityId, + object_type: 'activity', + user_id: userId, + custom: { + sentiment: 'positive', + }, + }, + ], + }); + // #endregion snippet + + const { comment } = await client.feeds.getComment({ id: commentId }); + expect(comment).toMatchObject({ + text: 'So great!', + object_id: activityId, + custom: { sentiment: 'positive' }, + user: { id: userId }, + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/04-file_uploads.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/04-file_uploads.test.ts new file mode 100644 index 0000000..6d4c78d --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/04-file_uploads.test.ts @@ -0,0 +1,129 @@ +import { mkdirSync, 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 { + ignoreMissingFile, + jpeg, + MINIMAL_PDF, +} from '../../../../helpers/uploads'; + +const DOCS = 'activity-feeds/v3-latest/_default/06-features/04-file_uploads.md'; + +// A 4x4 JPEG and a minimal PDF, written to a temp dir: the docs read +// `/assets/test-image.jpg` and `test-file.pdf`, and `import.meta.dirname` is +// COPY-mapped to the temp dir. +const fixturesDir = mkdtempSync(join(tmpdir(), 'docs-feeds-file-uploads-')); +mkdirSync(join(fixturesDir, 'assets')); +writeFileSync(join(fixturesDir, 'assets', 'test-image.jpg'), jpeg); +writeFileSync(join(fixturesDir, 'assets', 'test-file.pdf'), MINIMAL_PDF); + +const client = getServerClient(); +const cleanup = new Cleanup(client); +const userId = uniqueId('user'); +const user = { id: userId }; + +// The upload 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(userId); +cleanup.add(() => rm(fixturesDir, { recursive: true, force: true })); +await client.upsertUsers([user]); + +// #region snippet docs="activity-feeds/v3-latest/_default/06-features/04-file_uploads.md" heading="How to upload a file or image" tab="Node.js" index=1 +// COPY: fixturesDir=import.meta.dirname +import fs from 'fs'; +import path from 'path'; +import { File } from 'buffer'; + +const filePath = path.join(fixturesDir, 'assets', 'test-image.jpg'); +const fileBuffer = fs.readFileSync(filePath); + +const response = await client.uploadImage({ + file: new File([fileBuffer], 'test-image.jpg'), + user: { id: user.id }, + upload_sizes: [ + { + width: 100, + height: 100, + resize: 'scale', + crop: 'center', + }, + ], +}); + +const filePath2 = path.join(fixturesDir, 'assets', 'test-file.pdf'); +const fileBuffer2 = fs.readFileSync(filePath2); + +const fileResponse = await client.uploadFile({ + file: new File([fileBuffer2], 'test-file.pdf'), + user: { id: user.id }, +}); +// #endregion snippet + +for (const url of [response.file, fileResponse.file]) { + if (url) { + cleanup.add(() => + (url === response.file + ? client.deleteImage({ url }) + : client.deleteFile({ url }) + ).catch(ignoreMissingFile), + ); + } +} + +describe(DOCS, () => { + afterAll(() => cleanup.run()); + + it('uploads an image and a file', () => { + expect(response.file).toMatch(/^https:\/\//); + expect(response.upload_sizes).toMatchObject([ + { width: 100, height: 100, resize: 'scale', crop: 'center' }, + ]); + expect(fileResponse.file).toMatch(/^https:\/\//); + }); + + it('deletes an image and a file', async () => { + const { file: imageUrl } = await client.uploadImage({ + file: new File([jpeg], 'image.jpg', { type: 'image/jpeg' }), + user, + }); + const { file: fileUrl } = await client.uploadFile({ + file: new File([MINIMAL_PDF], 'file.pdf', { + type: 'application/pdf', + }), + user, + }); + if (!imageUrl || !fileUrl) throw new Error('upload returned no url'); + // In case the region fails before deleting them. + cleanup.add(() => + client.deleteImage({ url: imageUrl }).catch(ignoreMissingFile), + ); + cleanup.add(() => + client.deleteFile({ url: fileUrl }).catch(ignoreMissingFile), + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/04-file_uploads.md" heading="Deleting Files and Images" tab="Node.js" index=1 + // COPY: imageUrl="", fileUrl="" + await client.deleteImage({ + url: imageUrl, + }); + + await client.deleteFile({ + url: fileUrl, + }); + // #endregion snippet + + // Deleting them again fails: they are gone. + const missing = { code: 16, metadata: { responseCode: 404 } }; + await expect(client.deleteImage({ url: imageUrl })).rejects.toMatchObject( + missing, + ); + await expect(client.deleteFile({ url: fileUrl })).rejects.toMatchObject( + missing, + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/05-user-mentions.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/05-user-mentions.test.ts new file mode 100644 index 0000000..431af75 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/05-user-mentions.test.ts @@ -0,0 +1,164 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + notificationTypes, + waitForNotificationTypes, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = + 'activity-feeds/v3-latest/_default/06-features/05-user-mentions.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +describe(DOCS, () => { + let ericId: string; + let bobId: string; + let aliceId: string; + let ericFeed: string; + /** The activity with mentions added by the first snippet. */ + let mentionActivityId: string; + + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id, name }]); + return id; + }; + + const createFeed = async (group: string, id: string) => { + cleanup.feeds.push(`${group}:${id}`); + await client.feeds.feed(group, id).getOrCreate({ user_id: id }); + }; + + beforeAll(async () => { + ericId = await createUser('eric'); + bobId = await createUser('bob'); + aliceId = await createUser('alice'); + ericFeed = `user:${ericId}`; + await createFeed('user', ericId); + // `notification` feeds aren't created automatically. + await createFeed('notification', bobId); + await createFeed('notification', aliceId); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('adds an activity with mentions', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/05-user-mentions.md" heading="Adding an activity with mentions" tab="Node.js" index=1 + // COPY: ericFeed="user:eric", ericId="eric", bobId="bob", aliceId="alice" + const response = await client.feeds.addActivity({ + feeds: [ericFeed], + type: 'post', + text: 'Hey @bob and @alice, check this out!', + user_id: ericId, + mentioned_user_ids: [bobId, aliceId], + create_notification_activity: true, + }); + // response.activity.mentioned_users contains enriched user objects + // #endregion snippet + + mentionActivityId = response.activity.id; + expect( + response.activity.mentioned_users.map((u) => [u.id, u.name]), + ).toEqual([ + [bobId, 'bob'], + [aliceId, 'alice'], + ]); + // Each mentioned user gets a `mention` notification. + await waitForNotificationTypes(client, bobId, ['mention']); + await waitForNotificationTypes(client, aliceId, ['mention']); + }); + + it('adds a comment with mentions', async () => { + const { activity } = await client.feeds.addActivity({ + feeds: [ericFeed], + type: 'post', + text: 'An activity to comment on', + user_id: ericId, + }); + const activityId = activity.id; + const bobBefore = await notificationTypes(client, bobId); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/05-user-mentions.md" heading="Adding a comment with mentions" tab="Node.js" index=1 + // COPY: activityId="activity_123", aliceId="alice", bobId="bob" + const response = await client.feeds.addComment({ + comment: 'Thanks @bob for the tip!', + object_id: activityId, + object_type: 'activity', + user_id: aliceId, + mentioned_user_ids: [bobId], + create_notification_activity: true, + }); + // response.comment.mentioned_users contains enriched user objects + // #endregion snippet + + expect(response.comment.mentioned_users.map((u) => u.id)).toEqual([bobId]); + // Bob gets a `comment_mention` notification on top of the earlier ones. + await waitForNotificationTypes(client, bobId, [ + ...bobBefore, + 'comment_mention', + ]); + }); + + it('reads mentioned users', async () => { + // An activity with a commented mention (the first snippet's activity has the + // activity-level mentions). + const { comment } = await client.feeds.addComment({ + comment: 'Hi @alice', + object_id: mentionActivityId, + object_type: 'activity', + user_id: bobId, + mentioned_user_ids: [aliceId], + }); + expect(comment.mentioned_users.map((u) => u.id)).toEqual([aliceId]); + const activityId = mentionActivityId; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/05-user-mentions.md" heading="Reading mentioned users" tab="Node.js" index=1 + // COPY: ericId="eric" + // From activities + const response = await client.feeds.getOrCreateFeed({ + feed_group_id: 'user', + feed_id: ericId, + user_id: ericId, + }); + response.activities.forEach((activity) => { + if (activity.mentioned_users?.length) { + console.log( + 'Mentioned:', + activity.mentioned_users.map((u) => u.name).join(', '), + ); + } + }); + // From comments + const activityResponse = await client.feeds.getActivity({ + id: activityId, + comment_limit: 10, + }); + activityResponse.activity.comments.forEach((comment) => { + if (comment.mentioned_users?.length) { + console.log( + 'Comment mentioned:', + comment.mentioned_users.map((u) => u.name).join(', '), + ); + } + }); + // #endregion snippet + + expect(log.mock.calls).toEqual([ + ['Mentioned:', 'bob, alice'], + ['Comment mentioned:', 'alice'], + ]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/06-url-previews.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/06-url-previews.test.ts new file mode 100644 index 0000000..e00e1c8 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/06-url-previews.test.ts @@ -0,0 +1,266 @@ +import { + afterAll, + afterEach, + 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 { waitForAppSetting } from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'activity-feeds/v3-latest/_default/06-features/06-url-previews.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +/** + * `https://example.com/...` has no OG data ("could not find any opengraph data for the + * given URL", 2026-10-16), so the tests COPY-map the docs URLs to one that has. + */ +const enrichedUrl = 'https://getstream.io'; + +/** Retries `getActivity` until URL enrichment (async on the test app) added a preview. */ +const waitForActivityPreview = (id: string) => + retry( + async () => { + const { activity } = await client.feeds.getActivity({ id }); + if (!activity.attachments[0]) throw new Error('no preview yet'); + return activity; + }, + { timeout: 30000 }, + ); + +/** Retries `getComment` until URL enrichment added a preview. */ +const waitForCommentPreview = (id: string) => + retry( + async () => { + const { comment } = await client.feeds.getComment({ id }); + if (!comment.attachments?.[0]) throw new Error('no preview yet'); + return comment; + }, + { timeout: 30000 }, + ); + +describe(DOCS, () => { + let ericId: string; + let aliceId: string; + let ericFeed: string; + /** The enriched activity and comment from the first snippets. */ + let enrichedActivityId: string; + let enrichedCommentId: string; + + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + const createActivity = async () => { + const { activity } = await client.feeds.addActivity({ + feeds: [ericFeed], + type: 'post', + text: 'An activity to comment on', + user_id: ericId, + }); + return activity.id; + }; + + beforeAll(async () => { + ericId = await createUser('eric'); + aliceId = await createUser('alice'); + ericFeed = `user:${ericId}`; + cleanup.feeds.push(ericFeed); + await client.feeds.feed('user', ericId).getOrCreate({ user_id: ericId }); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('adds an activity with URL enrichment', async () => { + const postText = `Check this out: ${enrichedUrl}`; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/06-url-previews.md" heading="Activities with URL enrichment" tab="Node.js" index=1 + // COPY: ericFeed="user:eric", ericId="eric", postText="Check this out: https://example.com/article" + const response = await client.feeds.addActivity({ + feeds: [ericFeed], + type: 'post', + text: postText, + user_id: ericId, + }); + // response.activity.attachments may include OG preview when enrichment completes + // #endregion snippet + + enrichedActivityId = response.activity.id; + const activity = await waitForActivityPreview(enrichedActivityId); + expect(activity.attachments[0]).toMatchObject({ + og_scrape_url: enrichedUrl, + }); + expect(typeof activity.attachments[0].title).toBe('string'); + }); + + it('adds a comment with URL enrichment', async () => { + const activityId = enrichedActivityId; + const commentText = `See also: ${enrichedUrl}`; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/06-url-previews.md" heading="Comments with URL enrichment" tab="Node.js" index=1 + // COPY: aliceId="alice", commentText="See also: https://example.com/related" + const response = await client.feeds.addComment({ + comment: commentText, + object_id: activityId, + object_type: 'activity', + user_id: aliceId, + }); + // #endregion snippet + + enrichedCommentId = response.comment.id; + const comment = await waitForCommentPreview(enrichedCommentId); + expect(comment.attachments?.[0]).toMatchObject({ + og_scrape_url: enrichedUrl, + }); + }); + + it('skips URL enrichment', async () => { + const activityId = await createActivity(); + const pageUrl = enrichedUrl; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/06-url-previews.md" heading="Skipping URL enrichment" tab="Node.js" index=1 + // COPY: ericFeed="user:eric", ericId="eric", aliceId="alice", pageUrl="https://example.com/page" + // Activity + const activityResponse = await client.feeds.addActivity({ + feeds: [ericFeed], + type: 'post', + text: pageUrl, + user_id: ericId, + skip_enrich_url: true, + }); + // Comment + const commentResponse = await client.feeds.addComment({ + comment: pageUrl, + object_id: activityId, + object_type: 'activity', + user_id: aliceId, + skip_enrich_url: true, + }); + // #endregion snippet + + // The same URL is enriched within seconds without the flag (tests above). + await new Promise((resolve) => setTimeout(resolve, 8000)); + const { activity } = await client.feeds.getActivity({ + id: activityResponse.activity.id, + }); + expect(activity.attachments).toEqual([]); + const { comment } = await client.feeds.getComment({ + id: commentResponse.comment.id, + }); + expect(comment.attachments ?? []).toEqual([]); + }); + + it('reads URL preview attachments', async () => { + const activityId = enrichedActivityId; + // The enriched comment is the activity's only comment. + await waitForCommentPreview(enrichedCommentId); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/06-url-previews.md" heading="Reading URL preview attachments" tab="Node.js" index=1 + // From an activity + const { activity } = await client.feeds.getActivity({ id: activityId }); + (activity.attachments || []).forEach((a) => { + // use a.title, a.image_url, a.og_scrape_url, etc. + console.log(a.title, a.image_url, a.og_scrape_url); + }); + + // From a comment + const comment = activity.comments[0]; + (comment?.attachments || []).forEach((a) => { + // use a.title, a.image_url, a.og_scrape_url, etc. + console.log(a.title, a.image_url, a.og_scrape_url); + }); + // #endregion snippet + + expect(comment.id).toBe(enrichedCommentId); + expect(log.mock.calls).toHaveLength(2); + for (const call of log.mock.calls) { + expect(call[2]).toBe(enrichedUrl); + expect(typeof call[0]).toBe('string'); + } + }); + + it('adds OG data from getOG as attachments', async () => { + const activityId = await createActivity(); + const articleUrl = enrichedUrl; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/06-url-previews.md" heading="URL enrichment using `getOG` endpoint" tab="Node.js" index=1 + // COPY: articleUrl="https://example.com/article", ericFeed="user:eric", ericId="eric", aliceId="alice" + const url = articleUrl; + const attachments = []; + + try { + const ogResponse = await client.getOG({ url }); + const { duration: _d, metadata: _m, ...ogData } = ogResponse; + attachments.push(ogData); + } catch (error) { + // OG may not be fetched for all URLs + if (!(error instanceof StreamError && error.code === 4)) { + throw error; + } + } + + // Activity + const activityResponse = await client.feeds.addActivity({ + feeds: [ericFeed], + type: 'post', + text: `Check this out: ${url}`, + user_id: ericId, + skip_enrich_url: true, + attachments: attachments, + }); + // Comment + const commentResponse = await client.feeds.addComment({ + comment: `See also: ${url}`, + object_id: activityId, + object_type: 'activity', + user_id: aliceId, + skip_enrich_url: true, + attachments: attachments, + }); + // #endregion snippet + + expect(attachments).toHaveLength(1); + expect(activityResponse.activity.attachments).toHaveLength(1); + expect(activityResponse.activity.attachments[0]).toMatchObject({ + og_scrape_url: enrichedUrl, + title: attachments[0].title, + }); + expect(commentResponse.comment.attachments).toHaveLength(1); + expect(commentResponse.comment.attachments?.[0]).toMatchObject({ + og_scrape_url: enrichedUrl, + }); + }); + + it('switches between sync and async enrichment', async () => { + const { app } = await client.getApp(); + const original = app.async_url_enrich_enabled; + cleanup.add(async () => { + await client.updateApp({ async_url_enrich_enabled: original }); + await waitForAppSetting(client, 'async_url_enrich_enabled', original); + }); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/06-url-previews.md" heading="Sync vs async enrichment" tab="Node.js" index=1 + // Async enrichment: API returns immediately; OG data is attached in the background + await client.updateApp({ async_url_enrich_enabled: true }); + + // Sync enrichment: API waits for OG scraping (up to ~4s) before responding + await client.updateApp({ async_url_enrich_enabled: false }); + // #endregion snippet + + await waitForAppSetting(client, 'async_url_enrich_enabled', false); + await client.updateApp({ async_url_enrich_enabled: original }); + await waitForAppSetting(client, 'async_url_enrich_enabled', original); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/07-pins.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/07-pins.test.ts new file mode 100644 index 0000000..7f76211 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/07-pins.test.ts @@ -0,0 +1,143 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { + PinActivityResponse, + UnpinActivityResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'activity-feeds/v3-latest/_default/06-features/07-pins.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +describe(DOCS, () => { + /** A `uniqueId` user with their own `user` feed. */ + const createUserWithFeed = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + cleanup.feeds.push(`user:${id}`); + const feed = client.feeds.feed('user', id); + await feed.getOrCreate({ user_id: id }); + return { id, feed }; + }; + + const addActivity = async ( + userId: string, + text: string, + expiresAt?: Date, + ) => { + const { activity } = await client.feeds.addActivity({ + feeds: [`user:${userId}`], + type: 'post', + text, + user_id: userId, + expires_at: expiresAt?.toISOString(), + }); + return activity.id; + }; + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('pins and unpins an activity', async () => { + const { id: userId, feed } = await createUserWithFeed('owner'); + const activityId = await addActivity(userId, 'pin me'); + const pinActivity = vi.spyOn(client.feeds, 'pinActivity'); + const unpinActivity = vi.spyOn(client.feeds, 'unpinActivity'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/07-pins.md" heading="Overview" tab="Node.js" index=1 + // COPY: activityId="activity_123", userId="" + // Pin an activity + await client.feeds.pinActivity({ + feed_group_id: feed.group, + feed_id: feed.id, + activity_id: activityId, + user_id: userId, + }); + + // Unpin an activity + await client.feeds.unpinActivity({ + feed_group_id: feed.group, + feed_id: feed.id, + activity_id: activityId, + user_id: userId, + }); + // #endregion snippet + + const pinned = await (pinActivity.mock.results[0] + .value as Promise); + expect(pinned.activity.id).toBe(activityId); + const unpinned = await (unpinActivity.mock.results[0] + .value as Promise); + expect(unpinned.activity.id).toBe(activityId); + // The pin is gone. + const { pinned_activities } = await feed.getOrCreate({ user_id: userId }); + expect(pinned_activities).toEqual([]); + }); + + it('returns pinned activities when reading a feed', async () => { + const { id: saraId, feed } = await createUserWithFeed('sara'); + const activityId = await addActivity(saraId, 'pinned'); + await addActivity(saraId, 'not pinned'); + await feed.pinActivity({ activity_id: activityId, user_id: saraId }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/07-pins.md" heading="Overview" tab="Node.js" index=2 + // COPY: saraId="sara" + const response = await feed.getOrCreate({ user_id: saraId }); + console.log(response.pinned_activities); + // #endregion snippet + + expect(response.pinned_activities.map((p) => p.activity.id)).toEqual([ + activityId, + ]); + expect(log.mock.calls).toEqual([[response.pinned_activities]]); + }); + + it('queries pinned activities', async () => { + const { id: johnId, feed } = await createUserWithFeed('john'); + const activeId = await addActivity(johnId, 'active'); + const expiringId = await addActivity( + johnId, + 'expiring', + new Date(Date.now() + 3000), + ); + await feed.pinActivity({ activity_id: activeId, user_id: johnId }); + await feed.pinActivity({ activity_id: expiringId, user_id: johnId }); + // Let the second activity expire. + await new Promise((resolve) => setTimeout(resolve, 5000)); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/07-pins.md" heading="Query pinned activities" tab="Node.js" index=1 + // COPY: johnId="john" + const response = await client.feeds.queryPinnedActivities({ + feed_group_id: 'user', + feed_id: johnId, + include_moderated_activities: true, + include_expired_activities: true, + limit: 20, + }); + console.log(response.pinned_activities, response.next); + // #endregion snippet + + vi.restoreAllMocks(); + expect(response.pinned_activities.map((p) => p.activity.id).sort()).toEqual( + [activeId, expiringId].sort(), + ); + expect(log.mock.calls).toEqual([ + [response.pinned_activities, response.next], + ]); + // Control: without `include_expired_activities` the expired pin is left out. + const withoutExpired = await client.feeds.queryPinnedActivities({ + feed_group_id: 'user', + feed_id: johnId, + limit: 20, + }); + expect(withoutExpired.pinned_activities.map((p) => p.activity.id)).toEqual([ + activeId, + ]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/08-polls.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/08-polls.test.ts new file mode 100644 index 0000000..047f7f0 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/08-polls.test.ts @@ -0,0 +1,492 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + AddActivityResponse, + CreatePollRequest, + PollResponse, + PollVoteResponse, + PollVotesResponse, + QueryPollsResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { deletePollsCreatedBy } from '../../../../helpers/server'; + +const DOCS = 'activity-feeds/v3-latest/_default/06-features/08-polls.md'; + +const client = getServerClient(); +const cleanup = new Cleanup(client); + +describe(DOCS, () => { + // The docs' "": owns the feed and creates every poll. + const userId = uniqueId('poll-owner'); + const voterId = uniqueId('poll-voter'); + const feed = client.feeds.feed('user', userId); + + beforeAll(async () => { + cleanup.users.push(userId, voterId); + cleanup.feeds.push(`user:${userId}`); + // Polls survive their creator's deletion: delete them before the user. + cleanup.add(() => deletePollsCreatedBy(client, userId)); + await client.upsertUsers([{ id: userId }, { id: voterId }]); + await feed.getOrCreate({ user_id: userId }); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + /** A poll created by the owner 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: userId, + ...extra, + }); + return poll; + }; + + /** A poll attached to an activity in the owner's feed (votes need one). */ + const createPollActivity = async (extra: Partial = {}) => { + const poll = await createPoll(extra); + const { activity } = await client.feeds.addActivity({ + feeds: [feed.feed], + type: 'post', + text: 'Vote now', + poll_id: poll.id, + user_id: userId, + }); + return { poll, activityId: activity.id }; + }; + + it('Creating a Poll and Sending it as Part of an Activity', async () => { + const addActivity = vi.spyOn(client.feeds, 'addActivity'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Creating a Poll and Sending it as Part of an Activity" tab="Node.js" index=1 + // COPY: userId="" + const response = await client.createPoll({ + name: 'Where should we host our next company event?', + options: [ + { text: 'Amsterdam, The Netherlands' }, + { text: 'Boulder, CO' }, + ], + user_id: userId, + }); + + // Attach poll to an activity + await client.feeds.addActivity({ + feeds: [feed.feed], + type: 'post', + poll_id: response.poll.id, + user_id: userId, + }); + // #endregion snippet + + expect(response.poll.options.map((o) => o.text)).toEqual([ + 'Amsterdam, The Netherlands', + 'Boulder, CO', + ]); + const { activity } = await (addActivity.mock.results[0] + .value as Promise); + expect(activity.feeds).toEqual([`user:${userId}`]); + expect(activity.poll?.id).toBe(response.poll.id); + }); + + it('Overview of the poll and vote models', async () => { + const createPollSpy = vi.spyOn(client, 'createPoll'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Overview of the poll and vote models" tab="Node.js" index=1 + // COPY: userId="" + await client.createPoll({ + name: 'Where should we host our next company event?', + options: [ + { + text: 'Amsterdam, The Netherlands', + custom: { + country: 'Netherlands', + timezone: 'CET', + }, + }, + { + text: 'Boulder, CO', + custom: { + country: 'United States', + timezone: 'MST', + }, + }, + ], + custom: { + category: 'event_planning', + priority: 'high', + }, + user_id: userId, + }); + // #endregion snippet + + const { poll } = await (createPollSpy.mock.results[0] + .value as Promise); + expect(poll.custom).toMatchObject({ + category: 'event_planning', + priority: 'high', + }); + expect(poll.options.map((o) => o.custom)).toEqual([ + { country: 'Netherlands', timezone: 'CET' }, + { country: 'United States', timezone: 'MST' }, + ]); + }); + + it('Send Vote on Option', async () => { + const { poll, activityId } = await createPollActivity(); + const pollId = poll.id; + const optionId = poll.options[0].id; + const castPollVote = vi.spyOn(client.feeds, 'castPollVote'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Send Vote on Option" tab="Node.js" index=1 + // COPY: userId="", activityId="activity_123", pollId="poll_456", optionId="option_789" + await client.feeds.castPollVote({ + activity_id: activityId, + poll_id: pollId, + user_id: userId, + vote: { + option_id: optionId, + }, + }); + // #endregion snippet + + const { vote } = await (castPollVote.mock.results[0] + .value as Promise); + expect(vote?.option_id).toBe(optionId); + expect(vote?.user_id).toBe(userId); + const { poll: after } = await client.getPoll({ poll_id: pollId }); + expect(after.vote_counts_by_option).toEqual({ [optionId]: 1 }); + }); + + it('Send an Answer (if answers are configured to be allowed)', async () => { + const { poll, activityId } = await createPollActivity({ + allow_answers: true, + }); + const pollId = poll.id; + const castPollVote = vi.spyOn(client.feeds, 'castPollVote'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Send an Answer (if answers are configured to be allowed)" tab="Node.js" index=1 + // COPY: userId="", activityId="activity_123", pollId="poll_456" + await client.feeds.castPollVote({ + activity_id: activityId, + poll_id: pollId, + user_id: userId, + vote: { + answer_text: `Let's go somewhere else`, + }, + }); + // #endregion snippet + + const { vote } = await (castPollVote.mock.results[0] + .value as Promise); + expect(vote?.is_answer).toBe(true); + expect(vote?.answer_text).toBe(`Let's go somewhere else`); + }); + + it('Removing a Vote', async () => { + const { poll, activityId } = await createPollActivity(); + const pollId = poll.id; + const optionId = poll.options[0].id; + const { vote } = await client.feeds.castPollVote({ + activity_id: activityId, + poll_id: pollId, + user_id: userId, + vote: { option_id: optionId }, + }); + const voteId = vote?.id as string; + expect(voteId).toBeTruthy(); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Removing a Vote" tab="Node.js" index=1 + // COPY: userId="", activityId="activity_123", pollId="poll_456", voteId="vote_789" + await client.feeds.deletePollVote({ + activity_id: activityId, + poll_id: pollId, + vote_id: voteId, + user_id: userId, + }); + // #endregion snippet + + const { votes } = await client.queryPollVotes({ + poll_id: pollId, + user_id: userId, + }); + expect(votes).toEqual([]); + }); + + it('Closing a Poll', async () => { + const pollId = (await createPoll()).id; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Closing a Poll" tab="Node.js" index=1 + // COPY: userId="", pollId="poll_456" + await client.updatePollPartial({ + poll_id: pollId, + set: { + is_closed: true, + }, + user_id: userId, + }); + // #endregion snippet + + const { poll } = await client.getPoll({ poll_id: pollId }); + expect(poll.is_closed).toBe(true); + }); + + it('Retrieving a Poll', async () => { + const { poll } = await createPollActivity(); + const pollId = poll.id; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Retrieving a Poll" tab="Node.js" index=1 + // COPY: userId="", pollId="" + const response = await feed.getOrCreate({ user_id: userId }); + + console.log(response.activities[0].poll); + + // or use poll id + const pollResponse = await client.getPoll({ + poll_id: pollId, + user_id: userId, + }); + // #endregion snippet + + expect(response.activities[0].poll?.id).toBe(pollId); + expect(log.mock.calls[0][0]).toMatchObject({ id: pollId }); + expect(pollResponse.poll.id).toBe(pollId); + expect(pollResponse.poll.name).toBe('Test poll'); + }); + + it('Full Update', async () => { + const created = await createPoll({ description: 'To be reset' }); + const pollId = created.id; + const optionId = created.options[0].id; + const option123Id = created.options[1].id; + const updatePoll = vi.spyOn(client, 'updatePoll'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Full Update" tab="Node.js" index=1 + // COPY: userId="", pollId="poll_456", optionId="option_789", option123Id="option_123" + await client.updatePoll({ + id: pollId, + name: 'Where should we host our next company event?', + options: [ + { + id: optionId, + text: 'Amsterdam, The Netherlands', + custom: { + reason: 'too expensive', + }, + }, + { + id: option123Id, + text: 'Boulder, CO', + custom: { + reason: 'too far', + }, + }, + ], + user_id: userId, + }); + // #endregion snippet + + const { poll } = await (updatePoll.mock.results[0] + .value as Promise); + expect(poll.name).toBe('Where should we host our next company event?'); + // Omitted fields are reset. + expect(poll.description).toBe(''); + expect( + poll.options.map((o) => ({ id: o.id, text: o.text, custom: o.custom })), + ).toEqual([ + { + id: optionId, + text: 'Amsterdam, The Netherlands', + custom: { reason: 'too expensive' }, + }, + { id: option123Id, text: 'Boulder, CO', custom: { reason: 'too far' } }, + ]); + }); + + it('Partial Update', async () => { + const pollId = ( + await createPoll({ custom: { custom: 'to be unset', keep: 'me' } }) + ).id; + const updatePollPartial = vi.spyOn(client, 'updatePollPartial'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Partial Update" tab="Node.js" index=1 + // COPY: userId="", pollId="poll_456" + await client.updatePollPartial({ + poll_id: pollId, + set: { + name: 'Updated poll name', + }, + unset: ['custom'], + user_id: userId, + }); + // #endregion snippet + + const { poll } = await (updatePollPartial.mock.results[0] + .value as Promise); + expect(poll.name).toBe('Updated poll name'); + // `unset` names custom properties by their bare name. + expect(poll.custom).toEqual({ keep: 'me' }); + }); + + it('Deleting a Poll', async () => { + const pollId = (await createPoll()).id; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Deleting a Poll" tab="Node.js" index=1 + // COPY: userId="", pollId="poll_456" + await client.deletePoll({ + poll_id: pollId, + user_id: userId, + }); + // #endregion snippet + + const { polls } = await client.queryPolls({ + filter: { id: pollId }, + user_id: userId, + }); + expect(polls).toEqual([]); + }); + + it('Add Poll Option', async () => { + const pollId = (await createPoll()).id; + const optionUserId = userId; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Add Poll Option" tab="Node.js" index=1 + // COPY: optionUserId="user_123", pollId="poll_456" + await client.createPollOption({ + poll_id: pollId, + text: 'Another option', + custom: { + added_by: optionUserId, + }, + user_id: optionUserId, + }); + // #endregion snippet + + const { poll } = await client.getPoll({ poll_id: pollId }); + const added = poll.options.find((o) => o.text === 'Another option'); + expect(added?.custom).toEqual({ added_by: userId }); + expect(poll.options).toHaveLength(3); + }); + + it('Update Poll Option', async () => { + const created = await createPoll(); + const pollId = created.id; + const optionId = created.options[1].id; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Update Poll Option" tab="Node.js" index=1 + // COPY: userId="", pollId="poll_456", optionId="option_123" + await client.updatePollOption({ + poll_id: pollId, + id: optionId, + text: 'Updated option', + custom: { + my_custom_property: 'my_custom_value', + }, + user_id: userId, + }); + // #endregion snippet + + const { poll } = await client.getPoll({ poll_id: pollId }); + const updated = poll.options.find((o) => o.id === optionId); + expect(updated?.text).toBe('Updated option'); + expect(updated?.custom).toEqual({ my_custom_property: 'my_custom_value' }); + }); + + it('Delete Poll Option', async () => { + const created = await createPoll(); + const pollId = created.id; + const optionId = created.options[0].id; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Delete Poll Option" tab="Node.js" index=1 + // COPY: userId="", pollId="poll_456", optionId="option_789" + await client.deletePollOption({ + poll_id: pollId, + option_id: optionId, + user_id: userId, + }); + // #endregion snippet + + const { poll } = await client.getPoll({ poll_id: pollId }); + expect(poll.options.map((o) => o.id)).toEqual([created.options[1].id]); + }); + + it('Querying Votes', async () => { + const { poll, activityId } = await createPollActivity({ + options: [{ text: 'A' }, { text: 'B' }, { text: 'C' }], + }); + const pollId = poll.id; + const [optionId, option790Id, otherOptionId] = poll.options.map( + (o) => o.id, + ); + const vote = async (user_id: string, option_id: string) => + ( + await client.feeds.castPollVote({ + activity_id: activityId, + poll_id: pollId, + user_id, + vote: { option_id }, + }) + ).vote?.id; + const voteIds = [ + await vote(userId, optionId), + await vote(voterId, option790Id), + ]; + // A vote on a third option the filter leaves out. + await vote(voterId, otherOptionId); + const queryPollVotes = vi.spyOn(client, 'queryPollVotes'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Querying Votes" tab="Node.js" index=1 + // COPY: userId="", pollId="poll_456", optionId="option_789", option790Id="option_790" + await client.queryPollVotes({ + filter: { + option_id: { $in: [optionId, option790Id] }, + }, + user_id: userId, + poll_id: pollId, + }); + // #endregion snippet + + const { votes } = await (queryPollVotes.mock.results[0] + .value as Promise); + expect(votes.map((v) => v.id).sort()).toEqual([...voteIds].sort()); + }); + + it('Querying Polls', async () => { + // The filter matches every closed poll in the app: create two and check + // their order under the docs sort. + const older = await createPoll({ is_closed: true }); + const newer = await createPoll({ is_closed: true }); + const queryPolls = vi.spyOn(client, 'queryPolls'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/08-polls.md" heading="Querying Polls" tab="Node.js" index=1 + // COPY: userId="" + await client.queryPolls({ + filter: { + is_closed: true, + }, + sort: [{ field: 'created_at', direction: -1 }], + user_id: userId, + }); + // #endregion snippet + + const { polls } = await (queryPolls.mock.results[0] + .value as Promise); + expect(polls.every((p) => p.is_closed)).toBe(true); + const ids = polls.map((p) => p.id); + expect(ids).toContain(newer.id); + expect(ids).toContain(older.id); + expect(ids.indexOf(newer.id)).toBeLessThan(ids.indexOf(older.id)); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/10-moderation.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/10-moderation.test.ts new file mode 100644 index 0000000..69d3445 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/10-moderation.test.ts @@ -0,0 +1,403 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + QueryModerationConfigsResponse, + QueryReviewQueueResponse, +} from '../../../../../../index'; +import { StreamClient } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerUser, waitForReviewItem } from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'activity-feeds/v3-latest/_default/06-features/10-moderation.md'; + +// The docs' empty credential placeholders, COPY-mapped to the test app's. +const streamApiKey = process.env.STREAM_API_KEY as string; +const streamSecret = process.env.STREAM_SECRET as string; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const moderatorId = uniqueId('moderator'); + const ownerId = uniqueId('owner'); + // The docs' app-wide "feeds" policy: a test-owned key, so no feed of the app uses it. + const feedsConfigKey = uniqueId('feeds'); + + /** A fresh `uniqueId` user, removed in cleanup. */ + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + /** An activity by the owner in their `user` feed. */ + const addActivity = async (text: string) => { + const { activity } = await client.feeds.addActivity({ + feeds: [`user:${ownerId}`], + type: 'post', + text, + user_id: ownerId, + }); + return activity.id; + }; + + /** A ban's length in minutes (`expires` is exactly `created_at` + timeout). */ + const banMinutes = async (userId: string) => { + const { bans } = await client.queryBannedUsers({ + payload: { filter_conditions: { user_id: { $eq: userId } } }, + }); + expect(bans).toHaveLength(1); + return ( + ((bans[0].expires?.getTime() ?? 0) - bans[0].created_at.getTime()) / + 60_000 + ); + }; + + beforeAll(async () => { + cleanup.users.push(moderatorId, ownerId); + await client.upsertUsers([ + { id: moderatorId, role: 'admin' }, + { id: ownerId }, + ]); + cleanup.feeds.push(`user:${ownerId}`); + await client.feeds.feed('user', ownerId).getOrCreate({ user_id: ownerId }); + cleanup.add(() => + client.moderation + .deleteConfig({ key: feedsConfigKey }) + .catch((error: unknown) => { + if (!String(error).includes('not found')) throw error; + }), + ); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Overview', () => { + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Overview" tab="Node.js" index=1 + // COPY: streamApiKey="", streamSecret="" + const apiKey = streamApiKey; + const secret = streamSecret; + const client = new StreamClient(apiKey, secret); + + const moderation = client.moderation; + // #endregion snippet + + expect(typeof moderation.ban).toBe('function'); + }); + + it('Ban Users', async () => { + const banTarget = await createUser('ban-target'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Ban Users" tab="Node.js" index=1 + // COPY: banTarget="user-123", moderatorId="" + await client.moderation.ban({ + target_user_id: banTarget, + banned_by_id: moderatorId, + reason: 'Violation of community guidelines', + timeout: 60, // 1 hour in minutes + shadow: false, + ip_ban: false, + }); + // #endregion snippet + + const user = await getServerUser(client, banTarget); + expect(user.banned).toBe(true); + expect(await banMinutes(banTarget)).toBe(60); + }); + + it('Block Users', async () => { + const blockedUserId = await createUser('blocked'); + const blockingUserId = await createUser('blocking'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Block Users" tab="Node.js" index=1 + // COPY: blockedUserId="user-123", blockingUserId="user-456" + await client.blockUsers({ + blocked_user_id: blockedUserId, + user_id: blockingUserId, + }); + // #endregion snippet + + const { blocks } = await client.getBlockedUsers({ + user_id: blockingUserId, + }); + expect(blocks.map((b) => b.blocked_user_id)).toEqual([blockedUserId]); + }); + + it('Unblock Users', async () => { + const unblockedUserId = await createUser('unblocked'); + const unblockingUserId = await createUser('unblocking'); + await client.blockUsers({ + blocked_user_id: unblockedUserId, + user_id: unblockingUserId, + }); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Unblock Users" tab="Node.js" index=1 + // COPY: unblockedUserId="user-123", unblockingUserId="user-456" + await client.unblockUsers({ + blocked_user_id: unblockedUserId, + user_id: unblockingUserId, + }); + // #endregion snippet + + const { blocks } = await client.getBlockedUsers({ + user_id: unblockingUserId, + }); + expect(blocks).toEqual([]); + }); + + it('Get Blocked Users', async () => { + const blockedId = await createUser('get-blocked'); + const blockerId = await createUser('get-blocker'); + await client.blockUsers({ blocked_user_id: blockedId, user_id: blockerId }); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Get Blocked Users" tab="Node.js" index=1 + // COPY: blockerId="user-456" + const response = await client.getBlockedUsers({ user_id: blockerId }); + // #endregion snippet + + expect(response.blocks.map((b) => [b.user_id, b.blocked_user_id])).toEqual([ + [blockerId, blockedId], + ]); + }); + + it('Flag Content', async () => { + const activityId = await addActivity('flag me'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Flag Content" tab="Node.js" index=1 + // COPY: activityId="activity_123", moderatorId="" + await client.moderation.flag({ + entity_type: 'stream:feeds:v3:activity', + entity_id: activityId, + reason: 'Inappropriate content', + user_id: moderatorId, + }); + // #endregion snippet + + const item = await waitForReviewItem(client, activityId); + expect(item.entity_type).toBe('stream:feeds:v3:activity'); + expect(item.flags[0]).toMatchObject({ + reason: 'Inappropriate content', + user_id: moderatorId, + }); + }); + + it('Submit Moderation Actions', async () => { + const activityId = await addActivity('review me'); + await client.moderation.flag({ + entity_type: 'stream:feeds:v3:activity', + entity_id: activityId, + reason: 'spam', + user_id: moderatorId, + }); + const { id: reviewItemId } = await waitForReviewItem(client, activityId); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Submit Moderation Actions" tab="Node.js" index=1 + // COPY: reviewItemId="", moderatorId="" + await client.moderation.submitAction({ + // Action details for moderation + action_type: 'mark_reviewed', + item_id: reviewItemId, + user_id: moderatorId, + }); + // #endregion snippet + + const { item } = await client.moderation.getReviewQueueItem({ + id: reviewItemId, + }); + expect(item?.latest_moderator_action).toBe('mark_reviewed'); + }); + + it('Query Review Queue', async () => { + const activityId = await addActivity('queue me'); + await client.moderation.flag({ + entity_type: 'stream:feeds:v3:activity', + entity_id: activityId, + reason: 'spam', + user_id: moderatorId, + }); + await waitForReviewItem(client, activityId); + const user = { id: moderatorId }; + const queryReviewQueue = vi.spyOn(client.moderation, 'queryReviewQueue'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Query Review Queue" tab="Node.js" index=1 + await client.moderation.queryReviewQueue({ + // Query parameters for filtering and pagination + user_id: user.id, + }); + // #endregion snippet + + const { items } = await (queryReviewQueue.mock.results[0] + .value as Promise); + expect(items.length).toBeGreaterThan(0); + }); + + describe('Configuration Management', () => { + // The docs' "existing block lists", test-owned. + const blocklistName = uniqueId('remove-list'); + const flagBlocklistName = uniqueId('flag-list'); + const shadowBlocklistName = uniqueId('shadow-list'); + const bounceBlocklistName = uniqueId('bounce-list'); + + beforeAll(async () => { + for (const [i, name] of [ + blocklistName, + flagBlocklistName, + shadowBlocklistName, + bounceBlocklistName, + ].entries()) { + await client.createBlockList({ name, words: [`frobnicate${i}`] }); + cleanup.add(() => client.deleteBlockList({ name })); + } + }); + + it('Upsert Moderation Configuration', async () => { + const upsertConfig = client.moderation.upsertConfig.bind( + client.moderation, + ); + vi.spyOn(client.moderation, 'upsertConfig').mockImplementation( + (request) => + retry(() => upsertConfig(request), { + retryIf: (error) => String(error).includes('Blocklist not found'), + }), + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Upsert Moderation Configuration" tab="Node.js" index=1 + // COPY: feedsConfigKey="feeds" + await client.moderation.upsertConfig({ + key: feedsConfigKey, + block_list_config: { + enabled: true, + rules: [ + // Names of existing block lists + { name: blocklistName, action: 'remove' }, + { name: flagBlocklistName, action: 'flag' }, + { name: shadowBlocklistName, action: 'shadow' }, + { name: bounceBlocklistName, action: 'bounce' }, + ], + }, + ai_image_config: { + enabled: true, + rules: [ + { + label: 'Non-Explicit Nudity', + action: 'remove', + min_confidence: 0, + }, + ], + ocr_rules: [{ label: 'SEXUAL_HARASSMENT', action: 'remove' }], + }, + }); + // #endregion snippet + + const { config } = await client.moderation.getConfig({ + key: feedsConfigKey, + }); + expect( + config?.block_list_config?.rules?.map(({ name, action }) => [ + name, + action, + ]), + ).toEqual([ + [blocklistName, 'remove'], + [flagBlocklistName, 'flag'], + [shadowBlocklistName, 'shadow'], + [bounceBlocklistName, 'bounce'], + ]); + expect(config?.ai_image_config?.rules?.map((r) => r.label)).toEqual([ + 'Non-Explicit Nudity', + ]); + expect(config?.ai_image_config?.ocr_rules?.map((r) => r.label)).toEqual([ + 'SEXUAL_HARASSMENT', + ]); + }); + + it('Get Moderation Configuration', async () => { + const getConfig = vi.spyOn(client.moderation, 'getConfig'); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Get Moderation Configuration" tab="Node.js" index=1 + // COPY: feedsConfigKey="feeds" + await client.moderation.getConfig({ + key: feedsConfigKey, + }); + // #endregion snippet + + const { config } = await (getConfig.mock.results[0].value as ReturnType< + typeof client.moderation.getConfig + >); + expect(config?.key).toBe(feedsConfigKey); + }); + + it('Query Moderation Configurations', async () => { + const queryModerationConfigs = vi.spyOn( + client.moderation, + 'queryModerationConfigs', + ); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Query Moderation Configurations" tab="Node.js" index=1 + await client.moderation.queryModerationConfigs({ + filter: { + // Filter params + }, + }); + // #endregion snippet + + const { configs } = await (queryModerationConfigs.mock.results[0] + .value as Promise); + expect(configs.length).toBeGreaterThan(0); + }); + + it('Delete Moderation Configuration', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Delete Moderation Configuration" tab="Node.js" index=1 + // COPY: feedsConfigKey="feeds" + await client.moderation.deleteConfig({ + key: feedsConfigKey, + }); + // #endregion snippet + + await expect( + client.moderation.getConfig({ key: feedsConfigKey }), + ).rejects.toMatchObject({ metadata: { responseCode: 404 } }); + }); + }); + + it('Error Handling', async () => { + const banTarget = await createUser('error-ban-target'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/10-moderation.md" heading="Error Handling" tab="Node.js" index=1 + // COPY: banTarget="user-123", moderatorId="" + try { + await client.moderation.ban({ + target_user_id: banTarget, + banned_by_id: moderatorId, + reason: 'Violation of community guidelines', + timeout: 60, // 1 hour in minutes + shadow: false, + ip_ban: false, + }); + + console.log(`Banned successfully`); + } catch (e) { + console.error(`Failed to ban user, error: ${String(e)}`); + } + // #endregion snippet + + expect(log.mock.calls).toEqual([['Banned successfully']]); + expect(error).not.toHaveBeenCalled(); + expect(await banMinutes(banTarget)).toBe(60); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/11-collections.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/11-collections.test.ts new file mode 100644 index 0000000..879c166 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/06-features/11-collections.test.ts @@ -0,0 +1,361 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { AddActivityResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'activity-feeds/v3-latest/_default/06-features/11-collections.md'; + +/** + * Collections are app-level objects (not removed with a user or feed) and the leak check + * doesn't scan them: every collection gets a `uniqueId` name and id, cleanup deletes + * every ref the file created, and `afterAll` checks that none of the names is left. + */ +describe(DOCS, () => { + const client = getServerClient(); + // The "Upserting Collections" fence calls the server client `serverClient`. + const serverClient = client; + const cleanup = new Cleanup(client); + + const names: string[] = []; + const refs: string[] = []; + + /** A `uniqueId` collection name / id pair (not created), registered for cleanup. */ + const newCollection = (name = uniqueId('movies')) => { + const id = uniqueId('lotr'); + if (!names.includes(name)) names.push(name); + refs.push(`${name}:${id}`); + return { name, id, ref: `${name}:${id}` }; + }; + + /** Creates a collection with the page's movie data. */ + const createCollection = async (collection = newCollection()) => { + await client.feeds.createCollections({ + collections: [ + { + name: collection.name, + id: collection.id, + custom: { title: 'Lord of the Rings', genre: 'fantasy', rating: 9 }, + }, + ], + }); + return collection; + }; + + const readCollection = async (ref: string) => + (await client.feeds.readCollections({ collection_refs: [ref] })) + .collections; + + let jackId: string; + + beforeAll(async () => { + cleanup.add(async () => { + if (refs.length > 0) + await client.feeds.deleteCollections({ collection_refs: refs }); + }); + jackId = uniqueId('jack'); + cleanup.users.push(jackId); + cleanup.feeds.push(`user:${jackId}`); + await client.upsertUsers([{ id: jackId }]); + }); + + afterEach(() => vi.restoreAllMocks()); + + afterAll(async () => { + await cleanup.run(); + // Leak check for collections (not covered by setup.ts). + if (names.length === 0) return; + const { collections } = await client.feeds.queryCollections({ + filter: { name: { $in: names } }, + limit: 100, + }); + if (collections.length > 0) { + await client.feeds.deleteCollections({ + collection_refs: collections.map((c) => `${c.name}:${c.id}`), + }); + throw new Error( + `LEAK: collections ${collections.map((c) => `${c.name}:${c.id}`).join(', ')}`, + ); + } + }); + + it('Creating Collections', async () => { + const { name: collectionName, id: collectionId, ref } = newCollection(); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/11-collections.md" heading="Creating Collections" tab="Node.js" index=1 + // COPY: collectionName="movies", collectionId="lord_of_the_rings" + const response = await client.feeds.createCollections({ + collections: [ + { + name: collectionName, + id: collectionId, + custom: { + title: 'Lord of the Rings', + genre: 'fantasy', + rating: 9, + }, + }, + ], + }); + // #endregion snippet + + const expected = { + name: collectionName, + id: collectionId, + custom: { title: 'Lord of the Rings', genre: 'fantasy', rating: 9 }, + }; + expect(response.collections).toMatchObject([expected]); + expect(await readCollection(ref)).toMatchObject([expected]); + }); + + it('Updating Collections', async () => { + const { + name: collectionName, + id: collectionId, + ref, + } = await createCollection(); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/11-collections.md" heading="Updating Collections" tab="Node.js" index=1 + // COPY: collectionName="movies", collectionId="lord_of_the_rings" + const response = await client.feeds.updateCollections({ + collections: [ + { + name: collectionName, + id: collectionId, + custom: { + title: 'Lord of the Rings', + genre: 'fantasy', + rating: 9.5, + }, + }, + ], + }); + // #endregion snippet + + expect(response.collections).toMatchObject([ + { name: collectionName, id: collectionId, custom: { rating: 9.5 } }, + ]); + expect(await readCollection(ref)).toMatchObject([ + { custom: { title: 'Lord of the Rings', genre: 'fantasy', rating: 9.5 } }, + ]); + }); + + it('Upserting Collections', async () => { + // A collection that doesn't exist yet: the upsert creates it. + const { name: collectionName, id: collectionId, ref } = newCollection(); + expect(await readCollection(ref)).toEqual([]); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/11-collections.md" heading="Upserting Collections" tab="Node.js" index=1 + // COPY: collectionName="movies", collectionId="lord_of_the_rings" + const response = await serverClient.feeds.upsertCollections({ + collections: [ + { + name: collectionName, + id: collectionId, + custom: { + title: 'Lord of the Rings', + genre: 'fantasy', + rating: 9.5, + }, + }, + ], + }); + // #endregion snippet + + expect(response.collections).toMatchObject([ + { name: collectionName, id: collectionId, custom: { rating: 9.5 } }, + ]); + expect(await readCollection(ref)).toMatchObject([ + { custom: { title: 'Lord of the Rings', genre: 'fantasy', rating: 9.5 } }, + ]); + + // An existing collection: the same upsert updates it. + await client.feeds.upsertCollections({ + collections: [ + { name: collectionName, id: collectionId, custom: { rating: 8 } }, + ], + }); + expect(await readCollection(ref)).toMatchObject([ + { custom: { rating: 8 } }, + ]); + }); + + it('Deleting Collections', async () => { + const { ref: collectionRef } = await createCollection(); + expect(await readCollection(collectionRef)).toHaveLength(1); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/11-collections.md" heading="Deleting Collections" tab="Node.js" index=1 + // COPY: collectionRef="movies:lord_of_the_rings" + const response = await client.feeds.deleteCollections({ + collection_refs: [collectionRef], + }); + // #endregion snippet + + expect(typeof response.duration).toBe('string'); + expect(await readCollection(collectionRef)).toEqual([]); + }); + + it('Reading Collections', async () => { + const { name, id, ref: collectionRef } = await createCollection(); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/11-collections.md" heading="Reading Collections" tab="Node.js" index=1 + // COPY: collectionRef="movies:lord_of_the_rings" + const response = await client.feeds.readCollections({ + collection_refs: [collectionRef], + }); + // #endregion snippet + + expect(response.collections).toMatchObject([ + { + name, + id, + custom: { title: 'Lord of the Rings', genre: 'fantasy', rating: 9 }, + }, + ]); + }); + + it('Query Collections', async () => { + const collectionName = uniqueId('movies'); + // Created oldest first: ascending created_at returns them in this order. + const first = await createCollection(newCollection(collectionName)); + const second = await createCollection(newCollection(collectionName)); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/11-collections.md" heading="Query Collections" tab="Node.js" index=1 + // COPY: collectionName="movies" + const response = await client.feeds.queryCollections({ + filter: { + name: collectionName, + }, + sort: [{ field: 'created_at', direction: 1 }], + limit: 25, + }); + // #endregion snippet + + expect(response.collections.map((c) => c.id)).toEqual([ + first.id, + second.id, + ]); + expect(response.next).toBeUndefined(); + }); + + it('Adding Collections to an Activity', async () => { + const { ref: collectionRef } = await createCollection(); + const jackFeed = `user:${jackId}`; + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/11-collections.md" heading="Adding Collections to an Activity" tab="Node.js" index=1 + // COPY: jackFeed="user:jack", jackId="jack", collectionRef="movies:lord_of_the_rings" + const response = await client.feeds.addActivity({ + type: 'post', + feeds: [jackFeed], + text: 'I love this movie!', + user_id: jackId, + collection_refs: [collectionRef], + }); + // #endregion snippet + + expect(response.activity).toMatchObject({ + text: 'I love this movie!', + user: { id: jackId }, + collections: { + [collectionRef]: { + status: 'ok', + custom: { title: 'Lord of the Rings', genre: 'fantasy', rating: 9 }, + }, + }, + }); + }); + + it('Enrichment', async () => { + const { + name: collectionName, + id: collectionId, + ref: collectionRef, + } = newCollection(); + const jackFeed = `user:${jackId}`; + const addActivity = vi.spyOn(client.feeds, 'addActivity'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="activity-feeds/v3-latest/_default/06-features/11-collections.md" heading="Enrichment" tab="Node.js" index=1 + // COPY: collectionName="movies", collectionId="lord_of_the_rings", jackFeed="user:jack", jackId="jack", collectionRef="movies:lord_of_the_rings" + // Create a collection + await client.feeds.createCollections({ + collections: [ + { + name: collectionName, + id: collectionId, + custom: { + title: 'Lord of the Rings', + genre: 'fantasy', + rating: 9, + }, + }, + ], + }); + + // Add the reference to an activity + const feed = client.feeds.feed('user', jackId); + await client.feeds.addActivity({ + type: 'post', + feeds: [jackFeed], + text: 'I love this movie!', + collection_refs: [collectionRef], + user_id: jackId, + }); + + // Read the feed and see the enriched collection data + const response = await feed.getOrCreate({ user_id: jackId }); + console.log(response); + /* + { + "activities": [ + { + "type": "post", + "text": "I love this movie!", + "collections": { + "movies:lord_of_the_rings": { + "name": "movies", + "id": "lord_of_the_rings", + "custom": { + "title": "Lord of the Rings", + "genre": "fantasy", + "rating": 9 + }, + "user_id": "jack", + "created_at": "2025-01-01T00:00:00.000Z", + "updated_at": "2025-01-01T00:00:00.000Z", + "status": "ok" + } + } + } + ], + ... + } + */ + // #endregion snippet + + const { activity } = await (addActivity.mock.results[0] + .value as Promise); + expect(log.mock.calls).toEqual([[response]]); + const read = response.activities.find((a) => a.id === activity.id); + expect(read).toMatchObject({ + type: 'post', + text: 'I love this movie!', + collections: { + [collectionRef]: { + name: collectionName, + id: collectionId, + custom: { title: 'Lord of the Rings', genre: 'fantasy', rating: 9 }, + status: 'ok', + }, + }, + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/12-troubleshooting/01-rate_limits.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/12-troubleshooting/01-rate_limits.test.ts new file mode 100644 index 0000000..a99ac26 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/12-troubleshooting/01-rate_limits.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { getServerClient } from '../../../../helpers/clients'; + +const DOCS = + 'activity-feeds/v3-latest/_default/12-troubleshooting/01-rate_limits.md'; + +/** The platform keys of a rate limits response. */ +const platforms = (response: object) => + Object.keys(response) + .filter((k) => !['duration', 'metadata'].includes(k)) + .sort(); + +const every = [ + 'android', + 'ios', + 'server_side', + 'unity', + 'unity_console', + 'unity_desktop', + 'web', +]; + +describe(DOCS, () => { + const client = getServerClient(); + + it('Inspecting rate limits', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/12-troubleshooting/01-rate_limits.md" heading="Inspecting rate limits" tab="Node.js" index=1 + // 1. Get feeds rate limits, server-side platform + const serverSideLimits = await client.feeds.getFeedsRateLimits({ + server_side: true, + }); + + // 2. Get feeds rate limits, all platforms + const allLimits = await client.feeds.getFeedsRateLimits(); + + // 3. Get feeds rate limits, iOS and Android + const platformLimits = await client.feeds.getFeedsRateLimits({ + ios: true, + android: true, + }); + + // 4. Get feeds rate limits for specific endpoints + const endpointLimits = await client.feeds.getFeedsRateLimits({ + endpoints: 'QueryActivities,AddActivity,GetOrCreateFeed', + }); + + // Get rate limits (all platforms, common and moderation endpoints) + const rateLimits = await client.getRateLimits(); + // #endregion snippet + + expect(platforms(serverSideLimits)).toEqual(['server_side']); + expect(typeof serverSideLimits.server_side?.AddActivity?.limit).toBe( + 'number', + ); + // Feeds endpoints only. + expect(serverSideLimits.server_side?.QueryUsers).toBeUndefined(); + + expect(platforms(allLimits)).toEqual( + expect.arrayContaining(['android', 'ios', 'server_side', 'web']), + ); + + expect(platforms(platformLimits)).toEqual(['android', 'ios']); + + for (const platform of platforms(endpointLimits)) { + const limits = (endpointLimits as unknown as Record)[ + platform + ]; + expect(Object.keys(limits).sort()).toEqual([ + 'AddActivity', + 'GetOrCreateFeed', + 'QueryActivities', + ]); + } + + expect(platforms(rateLimits)).toEqual(every); + expect(rateLimits.server_side?.QueryUsers).toBeDefined(); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/14-best_practices/03-cutoff-date.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/14-best_practices/03-cutoff-date.test.ts new file mode 100644 index 0000000..f95a65b --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/14-best_practices/03-cutoff-date.test.ts @@ -0,0 +1,88 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { CreateFeedGroupResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { addActivityOnce, isFeedGroupMissing } from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/14-best_practices/03-cutoff-date.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('How to set a cutoff', async () => { + // Feed groups are app-wide: the docs' group gets a uniqueId id. + const teamUpdates = uniqueId('team-updates'); + cleanup.feedGroups.push(teamUpdates); + const createFeedGroup = vi.spyOn(client.feeds, 'createFeedGroup'); + + // #region snippet docs="activity-feeds/v3-latest/_default/14-best_practices/03-cutoff-date.md" heading="How to set a cutoff" tab="Node.js" index=1 + // COPY: teamUpdates="team_updates" + // Bound a filtered current_feed scan to the last 14 days. + await client.feeds.createFeedGroup({ + id: teamUpdates, + activity_selectors: [ + { + type: 'current_feed', + filter: { + search_data: { $contains: { category: 'announcement' } }, + }, + cutoff_window: '14d', + }, + ], + }); + // #endregion snippet + + const { feed_group } = await (createFeedGroup.mock.results[0] + .value as Promise); + expect(feed_group.id).toBe(teamUpdates); + expect(feed_group.activity_selectors).toMatchObject([ + { + type: 'current_feed', + filter: { search_data: { $contains: { category: 'announcement' } } }, + cutoff_window: '14d', + }, + ]); + + // The filtered selector returns only the announcement. + const userId = uniqueId('author'); + cleanup.users.push(userId); + await client.upsertUsers([{ id: userId }]); + const fid = `${teamUpdates}:${userId}`; + cleanup.feeds.push(fid); + const feed = client.feeds.feed(teamUpdates, userId); + await retry(() => feed.getOrCreate({ user_id: userId }), { + retryIf: isFeedGroupMissing, + }); + const announcement = await addActivityOnce(client, { + type: 'post', + feeds: [fid], + text: 'Office closed on Friday', + search_data: { category: 'announcement' }, + user_id: userId, + }); + await addActivityOnce(client, { + type: 'post', + feeds: [fid], + text: 'Lunch?', + search_data: { category: 'chat' }, + user_id: userId, + }); + const read = await retry( + async () => { + const response = await feed.getOrCreate({ user_id: userId }); + const ids = response.activities.map((a) => a.id); + if (ids.length !== 1) throw new Error(`got ${ids.join(', ')}`); + return ids; + }, + { retryIf: () => true }, + ); + expect(read).toEqual([announcement]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/14-best_practices/04-hot-feed-cache.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/14-best_practices/04-hot-feed-cache.test.ts new file mode 100644 index 0000000..096f294 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/14-best_practices/04-hot-feed-cache.test.ts @@ -0,0 +1,122 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + addActivityOnce, + isFeedGroupMissing, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = + 'activity-feeds/v3-latest/_default/14-best_practices/04-hot-feed-cache.md'; + +/** + * Hot feed cache is enabled by Stream support per app and feed, and a cached read looks + * like a normal one (the page: "Transparent fallback"), so the test can't tell whether a + * read was cached. It shows the fence's paginated read with the same `external_ranking` + * on both pages: the external data changes the order, and page 2 continues page 1. + */ +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('External ranking', async () => { + const systemId = uniqueId('system'); + cleanup.users.push(systemId); + await client.upsertUsers([{ id: systemId }]); + + // A ranked feed group whose score uses the fence's external data. + const groupId = uniqueId('ranked'); + cleanup.feedGroups.push(groupId); + await client.feeds.createFeedGroup({ + id: groupId, + ranking: { + type: 'expression', + score: + 'popularity * external.popularity_multiplier + share_count * external.share_multiplier', + defaults: { + external: { popularity_multiplier: 1, share_multiplier: 1 }, + }, + }, + }); + const fid = `${groupId}:${systemId}`; + cleanup.feeds.push(fid); + const feed = client.feeds.feed(groupId, systemId); + await retry(() => feed.getOrCreate({ user_id: systemId }), { + retryIf: isFeedGroupMissing, + }); + + // `shared` is older but shared once (on the user's own `user` feed), so it ranks + // above the newer `fresh` (score 3 + 100 vs 0). + const shared = await addActivityOnce(client, { + type: 'post', + feeds: [fid], + text: 'shared', + user_id: systemId, + }); + const fresh = await addActivityOnce(client, { + type: 'post', + feeds: [fid], + text: 'fresh', + user_id: systemId, + }); + cleanup.feeds.push(`user:${systemId}`); + await client.feeds.addActivity({ + type: 'post', + feeds: [`user:${systemId}`], + parent_id: shared, + text: 'share', + user_id: systemId, + }); + // Wait for the share to count in the ranking. + const external_ranking = { + popularity_multiplier: 1, + share_multiplier: 100, + }; + await retry(async () => { + const { activities } = await feed.getOrCreate({ + user_id: systemId, + external_ranking, + }); + expect(activities.map((a) => a.id)).toEqual([shared, fresh]); + }); + + const pageSize = 1; + // Reads of a new group can still hit an API node that doesn't know it yet. + retryOnFeedGroupMissing(client, ['getOrCreateFeed']); + + // #region snippet docs="activity-feeds/v3-latest/_default/14-best_practices/04-hot-feed-cache.md" heading="External ranking" tab="Node.js" index=1 + // COPY: systemId="system", pageSize=20 + // Server-side call (backend SDK / server token) + // Page 1 + const page1 = await feed.getOrCreate({ + user_id: systemId, + limit: pageSize, + external_ranking: { + popularity_multiplier: 1, + share_multiplier: 100, + }, + }); + + // Page 2: pass the same external_ranking + const page2 = await feed.getOrCreate({ + user_id: systemId, + limit: pageSize, + next: page1.next, + external_ranking: { + popularity_multiplier: 1, + share_multiplier: 100, + }, + }); + // #endregion snippet + + expect(page1.activities.map((a) => a.id)).toEqual([shared]); + expect(typeof page1.next).toBe('string'); + expect(page2.activities.map((a) => a.id)).toEqual([fresh]); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/15-push/02-feed_group_push_configuration.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/15-push/02-feed_group_push_configuration.test.ts new file mode 100644 index 0000000..93464e8 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/15-push/02-feed_group_push_configuration.test.ts @@ -0,0 +1,72 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { UpdateFeedGroupResponse } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { + copyFeedGroup, + retryOnFeedGroupMissing, +} from '../../../../helpers/feeds'; + +const DOCS = + 'activity-feeds/v3-latest/_default/15-push/02-feed_group_push_configuration.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Updating Feed Group Push Configuration', async () => { + // The built-in groups are app-wide: the fence runs on uniqueId copies. + const { id: notificationGroupId, builtIn: notification } = + await copyFeedGroup(client, cleanup, 'notification'); + const { id: userGroupId, builtIn: user } = await copyFeedGroup( + client, + cleanup, + 'user', + ); + expect(notification.push_notification?.enable_push).toBe(true); + expect(notification.aggregation?.format).toBeTruthy(); + expect(user.push_notification?.enable_push).toBe(true); + + retryOnFeedGroupMissing(client, ['updateFeedGroup']); + + // #region snippet docs="activity-feeds/v3-latest/_default/15-push/02-feed_group_push_configuration.md" heading="Updating Feed Group Push Configuration" tab="Node.js" index=1 + // COPY: notificationGroupId="notification", userGroupId="user" + // Update notification feed group to only send push for follows + await client.feeds.updateFeedGroup({ + id: notificationGroupId, + push_notification: { + enable_push: true, + push_types: ['follow'], // Only follows will trigger push notifications + }, + }); + + // Disable all push notifications for a feed group + await client.feeds.updateFeedGroup({ + id: userGroupId, + push_notification: { + enable_push: false, + }, + }); + // #endregion snippet + + // The spy retryOnFeedGroupMissing installed. + const update = vi.spyOn(client.feeds, 'updateFeedGroup'); + const [notificationUpdate, userUpdate] = await Promise.all( + update.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(notificationUpdate.feed_group).toMatchObject({ + id: notificationGroupId, + push_notification: { enable_push: true, push_types: ['follow'] }, + }); + // An update replaces every field: the fence also drops the group's aggregation. + expect(notificationUpdate.feed_group.aggregation).toBeUndefined(); + expect(userUpdate.feed_group.id).toBe(userGroupId); + // `enable_push: false` is left out of the response. + expect(userUpdate.feed_group.push_notification).toEqual({}); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/15-push/03-push_template.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/15-push/03-push_template.test.ts new file mode 100644 index 0000000..e5d331c --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/15-push/03-push_template.test.ts @@ -0,0 +1,112 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { + firebaseCredentials, + hasFirebaseCredentials, +} from '../../../../helpers/push'; + +const DOCS = 'activity-feeds/v3-latest/_default/15-push/03-push_template.md'; + +/** + * Both fences use the push provider `firebase`, which is the test app's own provider: + * tests must not touch it. The provider name is COPY-mapped to a `uniqueId` Firebase + * provider the test creates, which needs a real service account (the API rejects fake + * credentials with code 4 "firebase credentials are invalid"), so everything is skipped + * without `DOCS_PUSH_FIREBASE_CREDENTIALS`. Templates go away with the provider. + */ +describe.skipIf(!hasFirebaseCredentials)(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + 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, + }, + }); + }); + + afterAll(() => cleanup.run()); + + it('Example', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/15-push/03-push_template.md" heading="Example" tab="Node.js" index=1 + // COPY: providerName="firebase" + // Enable push notifications for comment_added events with custom template + const response = await client.upsertPushTemplate({ + enable_push: true, + event_type: 'feeds.comment.added', + push_provider_type: 'firebase', + push_provider_name: providerName, + template: JSON.stringify({ + data: { + sender: 'stream.feeds', + type: '{{ event_type }}', + body: '{{ truncate comment.text 150 }}', + title: '{{ sender.name }} commented on your activity', + fid: '{{ feed.fid }}', + activity_id: '{{ activity.id }}', + comment_id: '{{ comment.id }}', + receiver_id: '{{ receiver.id }}', + }, + android: { + priority: 'high', + }, + apns: { + payload: { + aps: { + alert: { + title: '{{ sender.name }} commented on your activity', + body: '{{ truncate comment.text 150 }}', + }, + sound: 'default', + 'mutable-content': 1, + 'content-available': 0, + category: 'stream.feeds', + }, + }, + }, + }), + }); + // #endregion snippet + + expect(response.template).toMatchObject({ + enable_push: true, + event_type: 'feeds.comment.added', + }); + const template = JSON.parse(response.template?.template ?? '{}') as { + data?: { title?: string }; + }; + expect(template.data?.title).toBe( + '{{ sender.name }} commented on your activity', + ); + }); + + it('Retrieving Push Templates', async () => { + // #region snippet docs="activity-feeds/v3-latest/_default/15-push/03-push_template.md" heading="Retrieving Push Templates" tab="Node.js" index=1 + // COPY: providerName="firebase" + // Retrieve push templates for a specific provider + const response = await client.getPushTemplates({ + push_provider_type: 'firebase', + push_provider_name: providerName, + }); + // #endregion snippet + + // The template the previous test stored. + expect( + response.templates.find((t) => t.event_type === 'feeds.comment.added'), + ).toMatchObject({ enable_push: true }); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/node/01-basics/02-installation.agent.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/node/01-basics/02-installation.agent.test.ts new file mode 100644 index 0000000..a6c22bf --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/node/01-basics/02-installation.agent.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } 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 secret = process.env.STREAM_SECRET as string; + +// #region snippet docs="activity-feeds/v3-latest/node/01-basics/02-installation.md" heading="Custom HTTP agent" tab="Node.js" index=1 +import { StreamClient } from '@stream-io/node-sdk'; +import { Agent } from 'undici'; + +const agent = new Agent({ + connections: 10, // max connections per origin + keepAliveTimeout: 55_000, // idle socket timeout in ms +}); + +const client = new StreamClient(apiKey, secret, { timeout: 3000, agent }); +// #endregion snippet + +describe('activity-feeds/v3-latest/node/01-basics/02-installation.md (Custom HTTP agent)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls the API through the custom undici Agent', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout'); + const dispatch = vi.spyOn(Agent.prototype, 'dispatch'); + + const response = await client.feeds.listFeedGroups(); + + expect(response.groups).toHaveProperty('user'); + expect(timeout).toHaveBeenCalledWith(3000); + expect(dispatch).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/node/01-basics/02-installation.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/node/01-basics/02-installation.test.ts new file mode 100644 index 0000000..ff2b177 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/node/01-basics/02-installation.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// The docs' empty placeholders are COPY-mapped to the test app's credentials. +const streamApiKey = process.env.STREAM_API_KEY as string; +const streamSecret = process.env.STREAM_SECRET as string; + +// #region snippet docs="activity-feeds/v3-latest/node/01-basics/02-installation.md" heading="Stream CLI and Agent Skills" tab="Node.js" index=1 +// COPY: streamApiKey="", streamSecret="" +import { StreamClient } from '@stream-io/node-sdk'; +// or +// const { StreamClient } = require("@stream-io/node-sdk"); + +const apiKey = streamApiKey; +const secret = streamSecret; +const client = new StreamClient(apiKey, secret); + +// optionally add timeout to API requests +// the default timeout is 3000ms +const clientWithTimeout = new StreamClient(apiKey, secret, { timeout: 3000 }); +// #endregion snippet + +describe('activity-feeds/v3-latest/node/01-basics/02-installation.md (server-side client)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a client with the default timeout', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout'); + + const response = await client.feeds.listFeedGroups(); + + expect(response.groups).toHaveProperty('user'); + expect(timeout).toHaveBeenCalledWith(3000); + }); + + it('creates a client with a custom timeout', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout'); + + const response = await clientWithTimeout.feeds.listFeedGroups(); + + expect(response.groups).toHaveProperty('user'); + expect(timeout).toHaveBeenCalledWith(3000); + }); +}); diff --git a/__tests__/docs-snippets/pages/activity-feeds/v3-latest/node/06-features/06-events.test.ts b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/node/06-features/06-events.test.ts new file mode 100644 index 0000000..aa56ca1 --- /dev/null +++ b/__tests__/docs-snippets/pages/activity-feeds/v3-latest/node/06-features/06-events.test.ts @@ -0,0 +1,64 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../../../../helpers/clients'; +import { Cleanup } from '../../../../../helpers/cleanup'; +import { + saveAndRestoreEventHooks, + waitForEventHooks, +} from '../../../../../helpers/server'; + +const DOCS = 'activity-feeds/v3-latest/node/06-features/06-events.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + beforeAll(async () => { + await saveAndRestoreEventHooks(client, cleanup); + }); + + afterAll(() => cleanup.run()); + + it('configures webhook event hooks', async () => { + // The docs' "" placeholder. + const webhookUrl = 'https://example.com/webhooks/stream/feeds'; + + // #region snippet docs="activity-feeds/v3-latest/node/06-features/06-events.md" heading="" tab="Node.js" index=1 + // COPY: webhookUrl="" + await client.updateApp({ + event_hooks: [ + { + hook_type: 'webhook', + enabled: true, + // Pass an empty array to subscribe to all events + event_types: [], + webhook_url: webhookUrl, + }, + { + hook_type: 'webhook', + enabled: true, + // Subscribe only to specific events + event_types: ['feeds.activity.added' /* ... */], + webhook_url: webhookUrl, + }, + ], + }); + // #endregion snippet + + // The array replaces every existing hook. + const hooks = await waitForEventHooks( + client, + (h) => + h.length === 2 && h.every((hook) => hook.webhook_url === webhookUrl), + ); + expect(hooks.map((h) => [h.hook_type, h.enabled])).toEqual([ + ['webhook', true], + ['webhook', true], + ]); + // Hook order isn't stable on read: an empty array reads back as every + // event type plus "*", the other hook keeps its one type. + const [allEvents, specificEvents] = [...hooks].sort( + (a, b) => (b.event_types?.length ?? 0) - (a.event_types?.length ?? 0), + ); + expect(allEvents.event_types).toEqual(expect.arrayContaining(['*'])); + expect(specificEvents.event_types).toEqual(['feeds.activity.added']); + }); +}); 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 bd909eb..6d8616b 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 @@ -322,7 +322,7 @@ describe('chat/_default/03-channels/02-query_channels.md', () => { // #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). + // without a top-level `cid` filter. expect(sorted(idsOf(response))).toEqual(sorted([mutedId, plainId])); }); @@ -363,7 +363,7 @@ describe('chat/_default/03-channels/02-query_channels.md', () => { // #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). + // the hidden filter has no effect. expect(sorted(idsOf(response))).toEqual([mutedId]); }); }); diff --git a/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.standalone.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.standalone.test.ts index 084999f..c43b130 100644 --- a/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.standalone.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.standalone.test.ts @@ -7,17 +7,16 @@ import { getServerClient } from '../../../helpers/clients'; import { Cleanup } from '../../../helpers/cleanup'; import { uniqueId } from '../../../helpers/ids'; import { getServerUser } from '../../../helpers/server'; +import { jpeg } from '../../../helpers/uploads'; // 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')); +writeFileSync(avatarPath, jpeg); const client = getServerClient(); const cleanup = new Cleanup(client); diff --git a/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts b/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts index 3f8f96b..33e5f24 100644 --- a/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts +++ b/__tests__/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts @@ -3,32 +3,23 @@ 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 { ignoreMissingFile, jpeg, MINIMAL_PDF } from '../../../helpers/uploads'; // 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'); +writeFileSync(image1Path, jpeg); +writeFileSync(image2Path, jpeg); +writeFileSync(reportPath, MINIMAL_PDF); 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'); @@ -124,7 +115,6 @@ describe('chat/_default/04-messages/02-file_uploads.md', () => { 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 }, diff --git a/__tests__/docs-snippets/pages/chat/05-features/06-campaign_api.test.ts b/__tests__/docs-snippets/pages/chat/05-features/06-campaign_api.test.ts index 45b5140..022d11c 100644 --- a/__tests__/docs-snippets/pages/chat/05-features/06-campaign_api.test.ts +++ b/__tests__/docs-snippets/pages/chat/05-features/06-campaign_api.test.ts @@ -12,6 +12,8 @@ import type { CampaignResponse, CreateSegmentResponse, QuerySegmentTargetsResponse, + StopCampaignResponse, + UpdateCampaignResponse, } from '../../../../../index'; import { getServerClient } from '../../../helpers/clients'; import { Cleanup } from '../../../helpers/cleanup'; @@ -321,6 +323,7 @@ describe(DOCS, () => { }); const updateCampaign = vi.spyOn(client.chat, 'updateCampaign'); const startCampaign = vi.spyOn(client.chat, 'startCampaign'); + const stopCampaign = vi.spyOn(client.chat, 'stopCampaign'); const listedSegmentId = sharedSegmentId; const encodedNext = ''; const anotherCampaignId = await createDraftCampaign(); @@ -377,10 +380,9 @@ describe(DOCS, () => { const [updated] = await Promise.all( updateCampaign.mock.results.map( - (r) => r.value as Promise, + (r) => r.value as Promise, ), ); - // Typed as `CampaignResponse`, but the API wraps it in `campaign` (see the report). expect(updated).toMatchObject({ campaign: { name: updatedName, @@ -390,6 +392,9 @@ describe(DOCS, () => { }, }); expect(startCampaign).toHaveBeenCalledTimes(2); + const stopped = await (stopCampaign.mock.results[0] + .value as Promise); + expect(stopped.campaign?.id).toBe(testCampaignId); const { campaign: started } = await client.chat.getCampaign({ id: anotherCampaignId, }); diff --git a/__tests__/docs-snippets/pages/chat/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 index 5d9080b..f5cb7f0 100644 --- a/__tests__/docs-snippets/pages/chat/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 @@ -14,12 +14,11 @@ import { Cleanup } from '../../../helpers/cleanup'; import { uniqueId } from '../../../helpers/ids'; import { waitForAppSetting } from '../../../helpers/server'; import { retry } from '../../../helpers/wait'; +import { jpeg } from '../../../helpers/uploads'; 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 = - '/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); @@ -219,7 +218,6 @@ describe(DOCS, () => { 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(() => diff --git a/__tests__/docs-snippets/pages/chat/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 index e584d0b..e5d5d89 100644 --- a/__tests__/docs-snippets/pages/chat/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 @@ -57,7 +57,7 @@ describe(DOCS, () => { // #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). + // messages with the channel-level blocklist's words. const after = await channelConfig(); expect(after.blocklist).toBe(blocklistName); expect(after.blocklist_behavior).toBe('block'); diff --git a/__tests__/docs-snippets/pages/chat/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 index 6b6e6aa..49b00a1 100644 --- a/__tests__/docs-snippets/pages/chat/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 @@ -124,8 +124,7 @@ describe(DOCS, () => { '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. + // `args` and `set` (the SDK leaves them out of the body). const command = await client.chat.getCommand({ name: ticketCommand }); expect(command).toMatchObject({ description: 'Create customer support tickets', diff --git a/__tests__/docs-snippets/pages/moderation/02-quick-start/02-quick-start-chat-moderation.test.ts b/__tests__/docs-snippets/pages/moderation/02-quick-start/02-quick-start-chat-moderation.test.ts new file mode 100644 index 0000000..7049fa6 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/02-quick-start/02-quick-start-chat-moderation.test.ts @@ -0,0 +1,185 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { Attachment } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser, sendServerMessage } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; + +const DOCS = + 'moderation/_default/02-quick-start/02-quick-start-chat-moderation.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const blockedWord = 'frobnicate'; + const blocklist = uniqueId('blocklist'); + const channelId = uniqueId('channel'); + const cid = `messaging:${channelId}`; + // A channel-level policy, so only this test's channel is moderated by it. + const configKey = `chat:${cid}`; + const channel = client.chat.channel('messaging', channelId); + + /** 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; + }; + + /** The review queue item of `entityId`, once its flag is there. */ + const waitForFlag = (entityId: string) => + retry(async () => { + const { items } = await client.moderation.queryReviewQueue({ + filter: { entity_id: entityId }, + }); + expect(items).toHaveLength(1); + expect(items[0].flags).toHaveLength(1); + return items[0].flags[0]; + }); + + beforeAll(async () => { + cleanup.channels.push(cid); + const creatorId = await createUser('creator'); + await channel.getOrCreate({ + data: { created_by_id: creatorId, members: [{ user_id: creatorId }] }, + }); + await client.createBlockList({ name: blocklist, words: [blockedWord] }); + cleanup.add(() => client.deleteBlockList({ name: blocklist })); + // A new blocklist isn't known to the moderation API right away. + await retry( + () => + client.moderation.upsertConfig({ + key: configKey, + block_list_config: { rules: [{ name: blocklist, action: 'remove' }] }, + }), + { retryIf: (error) => String(error).includes('Blocklist not found') }, + ); + cleanup.add(() => client.moderation.deleteConfig({ key: configKey })); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('moderates a server-side message with force_moderation', async () => { + const sentBy = await createUser('sender'); + await channel.update({ add_members: [{ user_id: sentBy }] }); + const text = `you ${blockedWord}`; + const attachments: Attachment[] = []; + + // Without force_moderation, server-side messages skip moderation. + const skipped = await sendServerMessage(client, cid, { + text, + user_id: sentBy, + }); + expect(skipped.type).toBe('regular'); + // The new channel policy and blocklist take a moment to reach every API node. + await retry(async () => { + const probe = await sendServerMessage( + client, + cid, + { text, user_id: sentBy }, + { force_moderation: true }, + ); + expect(probe.type).toBe('error'); + }); + + const channelType = 'messaging'; + + // #region snippet docs="moderation/_default/02-quick-start/02-quick-start-chat-moderation.md" heading="Server side integration" tab="Node.js" index=1 + const { message } = await client.chat.sendMessage({ + type: channelType, + id: channelId, + message: { text, attachments, user_id: sentBy }, + force_moderation: true, + }); + // #endregion snippet + + expect(message.type).toBe('error'); + expect(message.text).toBe('Message was blocked by moderation policies'); + expect(message.moderation).toMatchObject({ + action: 'remove', + original_text: text, + blocklists_matched: [blocklist], + }); + }); + + it('flags a user', async () => { + const flaggedUserId = await createUser('flagged'); + const reporterId = await createUser('reporter'); + + // #region snippet docs="moderation/_default/02-quick-start/02-quick-start-chat-moderation.md" heading="Flag User" tab="Node.js" index=1 + // COPY: flaggedUserId="user_id", reporterId="user_id" + await client.moderation.flag({ + entity_type: 'stream:user', + entity_id: flaggedUserId, + reason: 'reason', + user_id: reporterId, // User ID who is flagging the user, required only for server-side integration. + }); + // #endregion snippet + + const flag = await waitForFlag(flaggedUserId); + expect(flag.reason).toBe('reason'); + expect(flag.user?.id).toBe(reporterId); + }); + + it('flags a message', async () => { + const authorId = await createUser('author'); + const reporterId = await createUser('reporter'); + await channel.update({ + add_members: [{ user_id: authorId }, { user_id: reporterId }], + }); + const { id: messageId } = await sendServerMessage(client, cid, { + text: 'hello', + user_id: authorId, + }); + + // #region snippet docs="moderation/_default/02-quick-start/02-quick-start-chat-moderation.md" heading="Flag Message" tab="Node.js" index=1 + // COPY: messageId="message_id", reporterId="user_id" + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: messageId, + reason: 'reason', + user_id: reporterId, // User ID who is flagging the message, required only for server-side integration. + }); + // #endregion snippet + + const flag = await waitForFlag(messageId); + expect(flag.reason).toBe('reason'); + expect(flag.user?.id).toBe(reporterId); + }); + + it('bans and unbans a user', async () => { + const targetUserId = await createUser('target'); + const moderatorId = await createUser('moderator'); + expect((await getServerUser(client, targetUserId)).banned).toBe(false); + + // #region snippet docs="moderation/_default/02-quick-start/02-quick-start-chat-moderation.md" heading="Ban User" tab="Node.js" index=1 + // COPY: targetUserId="target_user_id", moderatorId="user_id" + await client.moderation.ban({ + target_user_id: targetUserId, + banned_by_id: moderatorId, // User ID who is banning the user. + }); + // #endregion snippet + + expect((await getServerUser(client, targetUserId)).banned).toBe(true); + + // #region snippet docs="moderation/_default/02-quick-start/02-quick-start-chat-moderation.md" heading="Unban User" tab="Node.js" index=1 + // COPY: targetUserId="target_user_id" + await client.moderation.unban({ + target_user_id: targetUserId, + }); + // #endregion snippet + + expect((await getServerUser(client, targetUserId)).banned).toBe(false); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/02-quick-start/03-quick-start-feeds-moderation.test.ts b/__tests__/docs-snippets/pages/moderation/02-quick-start/03-quick-start-feeds-moderation.test.ts new file mode 100644 index 0000000..129c53e --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/02-quick-start/03-quick-start-feeds-moderation.test.ts @@ -0,0 +1,92 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { QueryFeedModerationTemplatesResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +const DOCS = + 'moderation/_default/02-quick-start/03-quick-start-feeds-moderation.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + // The docs' "mod_tmpl_1" template, test-owned. + const templateName = uniqueId('mod-tmpl'); + + /** + * Feed moderation templates aren't covered by the leak check, and the generated + * `v2DeleteTemplate()` can't send the template name (the API reads it from the + * request body), so cleanup sends the delete request itself. + */ + const deleteTemplate = (name: string) => + client.moderation.apiClient + .sendRequest( + 'DELETE', + '/api/v2/moderation/feeds_moderation_template', + undefined, + undefined, + { name }, + ) + .catch((error: unknown) => { + if (!String(error).includes('does not exist')) throw error; + }); + + const findTemplate = async (name: string) => { + const { templates } = await client.moderation.v2QueryTemplates(); + return templates.find((template) => template.name === name); + }; + + afterEach(() => vi.restoreAllMocks()); + afterAll(async () => { + await cleanup.run(); + expect(await findTemplate(templateName)).toBeUndefined(); + }); + + it('creates a template via the API', async () => { + cleanup.add(() => deleteTemplate(templateName)); + + // #region snippet docs="moderation/_default/02-quick-start/03-quick-start-feeds-moderation.md" heading="Create a Template via API" tab="Node.js" index=1 + // COPY: templateName="mod_tmpl_1" + await client.moderation.v2UpsertTemplate({ + name: templateName, + config: { + data_types: { text: 'text', attachments: 'image' }, + config_key: 'feeds:default', + }, + }); + // #endregion snippet + + const template = await findTemplate(templateName); + expect(template?.config).toMatchObject({ + data_types: { text: 'text', attachments: 'image' }, + config_key: 'feeds:default', + }); + }); + + it('queries templates', async () => { + const queryTemplates = vi.spyOn(client.moderation, 'v2QueryTemplates'); + + // #region snippet docs="moderation/_default/02-quick-start/03-quick-start-feeds-moderation.md" heading="Query Templates" tab="Node.js" index=1 + await client.moderation.v2QueryTemplates(); + // #endregion snippet + + const [response] = await Promise.all( + queryTemplates.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(response.templates.map(({ name }) => name)).toContain(templateName); + }); + + // The fence is right for the API, but the generated `v2DeleteTemplate()` takes no + // arguments (TS2554) and sends no name, so it fails with code 4 "name is + // a required field". Cleanup deletes the template instead. + it.skip('BLOCKED: SDK issue: v2DeleteTemplate() cannot send the template name', async () => { + // #region snippet docs="moderation/_default/02-quick-start/03-quick-start-feeds-moderation.md" heading="Delete Template" tab="Node.js" index=1 + // COPY: templateName="mod_tmpl_1" + await client.moderation.v2DeleteTemplate({ name: templateName }); + // #endregion snippet + + expect(await findTemplate(templateName)).toBeUndefined(); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/02-quick-start/07-quick-start-call-moderation.test.ts b/__tests__/docs-snippets/pages/moderation/02-quick-start/07-quick-start-call-moderation.test.ts new file mode 100644 index 0000000..f71a9d4 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/02-quick-start/07-quick-start-call-moderation.test.ts @@ -0,0 +1,249 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { UpsertModerationRuleResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +const DOCS = + 'moderation/_default/02-quick-start/07-quick-start-call-moderation.md'; + +// Values the module-level region below uses (it must come after them: TDZ). +const apiKey = process.env.STREAM_API_KEY as string; +const secret = process.env.STREAM_SECRET as string; +// The docs' "my-room-12345" entity and "user-abc" creator, test-owned. +const roomEntityId = uniqueId('my-room'); +const creatorId = uniqueId('user-abc'); +// A real image: an unreachable one makes the check return `status: "partial"`. +const keyframeUrl = 'https://getstream.io/images/og/OG_Home.png'; +await getServerClient().upsertUsers([{ id: creatorId }]); + +// #region snippet docs="moderation/_default/02-quick-start/07-quick-start-call-moderation.md" heading="Sending Keyframes" tab="Node.js" index=1 +// COPY: roomEntityId="my-room-12345", creatorId="user-abc", keyframeUrl="https://your-cdn.com/keyframe-001.jpg" +import { StreamClient } from '@stream-io/node-sdk'; + +const client = new StreamClient(apiKey, secret); + +const response = await client.moderation.check({ + entity_type: 'external:call', + entity_id: roomEntityId, + entity_creator_id: creatorId, + content_published_at: new Date(), + moderation_payload: { + images: [keyframeUrl], + }, +}); +// #endregion snippet + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + cleanup.users.push(creatorId); + + /** Deletes the rules `upsertModerationRule` created in a region, by the ids it returned. */ + const deleteUpsertedRules = (spy: { + mock: { results: Array<{ value: unknown }> }; + }) => + Promise.all( + spy.mock.results.map(async (r) => { + const { rule } = + await (r.value as Promise); + if (!rule) throw new Error('upsertModerationRule returned no rule'); + cleanup.add(() => + serverClient.moderation.deleteModerationRule({ id: rule.id }), + ); + return rule; + }), + ); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates a call rule via the API', async () => { + // The docs' rule name, test-owned (rules are app-wide and identified by name). + const callRuleName = uniqueId('inappropriate-content-detection'); + const upsertRule = vi.spyOn(client.moderation, 'upsertModerationRule'); + + // #region snippet docs="moderation/_default/02-quick-start/07-quick-start-call-moderation.md" heading="Configure Via API" tab="Node.js" index=1 + // COPY: callRuleName="Inappropriate Content Detection" + await client.moderation.upsertModerationRule({ + name: callRuleName, + description: + 'Detects and responds to inappropriate content in live video and audio', + rule_type: 'call', + enabled: true, + cooldown_period: '5m', + logic: 'OR', + conditions: [ + { + type: 'keyframe_rule', + keyframe_rule_params: { + harm_labels: ['NUDITY', 'VIOLENCE'], + threshold: 3, + time_window: '1m', + min_confidence: 0.75, + }, + }, + { + type: 'closed_caption_rule', + closed_caption_rule_params: { + threshold: 2, + time_window: '1m', + llm_harm_labels: { + HATE_SPEECH: + 'Content that promotes hatred, discrimination, or violence', + }, + }, + }, + ], + action_sequences: [ + { + violation_number: 1, + actions: ['mute_video', 'call_warning'], + call_options: { warning_text: 'Please keep your video appropriate' }, + }, + { violation_number: 2, actions: ['mute_audio', 'mute_video'] }, + { violation_number: 3, actions: ['kick_user'] }, + ], + }); + // #endregion snippet + + const [rule] = await deleteUpsertedRules(upsertRule); + const { rule: stored } = await client.moderation.getModerationRule({ + id: rule.id, + }); + expect(stored).toMatchObject({ + name: callRuleName, + rule_type: 'call', + enabled: true, + logic: 'OR', + cooldown_period: '5m', + }); + expect(stored?.conditions?.map(({ type }) => type)).toEqual([ + 'keyframe_rule', + 'closed_caption_rule', + ]); + expect(stored?.conditions?.[0].keyframe_rule_params).toMatchObject({ + harm_labels: ['NUDITY', 'VIOLENCE'], + threshold: 3, + min_confidence: 0.75, + }); + expect( + stored?.action_sequences?.map(({ violation_number, actions }) => ({ + violation_number, + actions, + })), + ).toEqual([ + { violation_number: 1, actions: ['mute_video', 'call_warning'] }, + { violation_number: 2, actions: ['mute_audio', 'mute_video'] }, + { violation_number: 3, actions: ['kick_user'] }, + ]); + }); + + it('creates a cross-call violation user rule via the API', async () => { + const userRuleName = uniqueId('ban-repeat-call-offenders'); + const upsertRule = vi.spyOn(client.moderation, 'upsertModerationRule'); + + // #region snippet docs="moderation/_default/02-quick-start/07-quick-start-call-moderation.md" heading="Configure Via API" tab="Node.js" index=2 + // COPY: userRuleName="Ban Repeat Call Offenders" + await client.moderation.upsertModerationRule({ + name: userRuleName, + rule_type: 'user', + enabled: true, + cooldown_period: '24h', + logic: 'AND', + conditions: [ + { + type: 'call_violation_count', + call_violation_count_params: { threshold: 5, time_window: '24h' }, + }, + ], + action: { type: 'ban_user' }, + }); + // #endregion snippet + + const [rule] = await deleteUpsertedRules(upsertRule); + expect(rule).toMatchObject({ + name: userRuleName, + rule_type: 'user', + action: { type: 'ban_user' }, + conditions: [ + { + type: 'call_violation_count', + call_violation_count_params: { threshold: 5, time_window: '24h' }, + }, + ], + }); + }); + + it('sends keyframes', () => { + // The check ran at module level (the fence imports the SDK). + expect(client).toBeInstanceOf(StreamClient); + expect(response.status).toBe('complete'); + expect(response.recommended_action).toBe('keep'); + }); + + it('sends closed captions', async () => { + // #region snippet docs="moderation/_default/02-quick-start/07-quick-start-call-moderation.md" heading="Sending Closed Captions" tab="Node.js" index=1 + // COPY: roomEntityId="my-room-12345", creatorId="user-abc" + const response = await client.moderation.check({ + entity_type: 'external:call', + entity_id: roomEntityId, + entity_creator_id: creatorId, + content_published_at: new Date(), + moderation_payload: { + texts: ['transcribed audio segment from the call'], + }, + }); + // #endregion snippet + + expect(response.status).toBe('complete'); + expect(response.recommended_action).toBe('keep'); + }); + + it('runs a continuous moderation loop', async () => { + const roomId = uniqueId('room'); + + // #region snippet docs="moderation/_default/02-quick-start/07-quick-start-call-moderation.md" heading="Example: Continuous Moderation Loop" tab="Node.js" index=1 + const ENTITY_ID = `room-${roomId}`; + const INTERVAL_MS = 5000; + + async function moderateKeyframe(imageUrl: string, userId: string) { + return client.moderation.check({ + entity_type: 'external:call', + entity_id: ENTITY_ID, + entity_creator_id: userId, + content_published_at: new Date(), + moderation_payload: { + images: [imageUrl], + }, + }); + } + + async function moderateCaption(text: string, userId: string) { + return client.moderation.check({ + entity_type: 'external:call', + entity_id: ENTITY_ID, + entity_creator_id: userId, + content_published_at: new Date(), + moderation_payload: { + texts: [text], + }, + }); + } + // #endregion snippet + + expect(INTERVAL_MS).toBe(5000); + const check = vi.spyOn(client.moderation, 'check'); + const keyframe = await moderateKeyframe(keyframeUrl, creatorId); + const caption = await moderateCaption('hello everyone', creatorId); + // With the page's call rule (keyframe condition) in place, the image is analysed + // asynchronously: `status: "partial"` plus a `task_id`. + expect(['complete', 'partial']).toContain(keyframe.status); + expect(keyframe.recommended_action).toBe('keep'); + expect(caption.status).toBe('complete'); + expect(check.mock.calls.map(([request]) => request.entity_id)).toEqual([ + ENTITY_ID, + ENTITY_ID, + ]); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/02-quick-start/08-quick-start-feeds-v3-moderation.test.ts b/__tests__/docs-snippets/pages/moderation/02-quick-start/08-quick-start-feeds-v3-moderation.test.ts new file mode 100644 index 0000000..9d8eba3 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/02-quick-start/08-quick-start-feeds-v3-moderation.test.ts @@ -0,0 +1,222 @@ +import { + afterAll, + afterEach, + 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 { getServerUser } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; + +const DOCS = + 'moderation/_default/02-quick-start/08-quick-start-feeds-v3-moderation.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const blockedWord = 'frobnicate'; + // The docs' "profanity_en" blocklist, test-owned. + const profanityBlocklist = uniqueId('profanity'); + + /** 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; + }; + + /** Deletes moderation config `key` in cleanup (a snippet may not have created it). */ + const deleteConfigInCleanup = (key: string) => + cleanup.add(() => + client.moderation.deleteConfig({ key }).catch((error: unknown) => { + if (!String(error).includes('not found')) throw error; + }), + ); + + /** `upsertConfig` retried while the new blocklist isn't known everywhere yet. */ + const isBlocklistMissing = (error: unknown) => + String(error).includes('Blocklist not found'); + + beforeAll(async () => { + await client.createBlockList({ + name: profanityBlocklist, + words: [blockedWord], + }); + cleanup.add(() => client.deleteBlockList({ name: profanityBlocklist })); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('sets up a policy', async () => { + // The docs' app-wide "feeds" policy: a test-owned key, so no feed of the app uses it. + const feedsConfigKey = uniqueId('feeds'); + deleteConfigInCleanup(feedsConfigKey); + const upsertConfig = client.moderation.upsertConfig.bind(client.moderation); + vi.spyOn(client.moderation, 'upsertConfig').mockImplementation((request) => + retry(() => upsertConfig(request), { retryIf: isBlocklistMissing }), + ); + + // #region snippet docs="moderation/_default/02-quick-start/08-quick-start-feeds-v3-moderation.md" heading="Configure Moderation Policy" tab="Node.js" index=1 + // COPY: feedsConfigKey="feeds", profanityBlocklist="profanity_en" + await client.moderation.upsertConfig({ + key: feedsConfigKey, + ai_text_config: { + rules: [ + { label: 'SPAM', action: 'flag' }, + { label: 'HATRED', action: 'remove' }, + ], + }, + block_list_config: { + rules: [{ name: profanityBlocklist, action: 'remove' }], + }, + ai_image_config: { + rules: [ + { label: 'Non-Explicit Nudity', action: 'flag', min_confidence: 50 }, + ], + }, + }); + // #endregion snippet + + const { config } = await client.moderation.getConfig({ + key: feedsConfigKey, + }); + expect( + config?.ai_text_config?.rules?.map(({ label, action }) => ({ + label, + action, + })), + ).toEqual([ + { label: 'SPAM', action: 'flag' }, + { label: 'HATRED', action: 'remove' }, + ]); + expect( + config?.block_list_config?.rules?.map(({ name, action }) => ({ + name, + action, + })), + ).toEqual([{ name: profanityBlocklist, action: 'remove' }]); + expect( + config?.ai_image_config?.rules?.map( + ({ label, action, min_confidence }) => ({ + label, + action, + min_confidence, + }), + ), + ).toEqual([ + { label: 'Non-Explicit Nudity', action: 'flag', min_confidence: 50 }, + ]); + }); + + it('assigns a named policy to a feed', async () => { + const ownerId = await createUser('owner'); + const feedId = uniqueId('feed'); + cleanup.feeds.push(`user:${feedId}`); + const feed = client.feeds.feed('user', feedId); + await feed.getOrCreate({ user_id: ownerId }); + // The docs' "pm:acme" policy: blocks the test blocklist's words. + const namedPolicy = uniqueId('pm'); + deleteConfigInCleanup(namedPolicy); + await retry( + () => + client.moderation.upsertConfig({ + key: namedPolicy, + block_list_config: { + rules: [{ name: profanityBlocklist, action: 'remove' }], + }, + }), + { retryIf: isBlocklistMissing }, + ); + + // #region snippet docs="moderation/_default/02-quick-start/08-quick-start-feeds-v3-moderation.md" heading="Assign a named policy to a feed" tab="Node.js" index=1 + // COPY: namedPolicy="pm:acme" + await feed.update({ + custom: { + moderation_policy: namedPolicy, + }, + }); + // #endregion snippet + + const { feed: stored } = await feed.getOrCreate({ user_id: ownerId }); + expect(stored.custom).toEqual({ moderation_policy: namedPolicy }); + + // The feed's activities are now moderated with that policy. + const moderation = await retry(async () => { + const { activity } = await client.feeds.addActivity({ + type: 'post', + feeds: [`user:${feedId}`], + text: `you ${blockedWord}`, + user_id: ownerId, + force_moderation: true, + }); + expect(activity.moderation?.action).toBe('remove'); + return activity.moderation; + }); + expect(moderation?.blocklists_matched).toEqual([profanityBlocklist]); + }); + + it('flags a user', async () => { + const targetUserId = await createUser('target'); + const reportingUserId = await createUser('reporter'); + + // #region snippet docs="moderation/_default/02-quick-start/08-quick-start-feeds-v3-moderation.md" heading="Flag a User" tab="Node.js" index=1 + await client.moderation.flag({ + entity_type: 'stream:user', + entity_id: targetUserId, + reason: 'harassment', + user_id: reportingUserId, + }); + // #endregion snippet + + const flag = await retry(async () => { + const { items } = await client.moderation.queryReviewQueue({ + filter: { entity_id: targetUserId }, + }); + expect(items).toHaveLength(1); + expect(items[0].flags).toHaveLength(1); + return items[0].flags[0]; + }); + expect(flag.reason).toBe('harassment'); + expect(flag.user?.id).toBe(reportingUserId); + }); + + it('bans a user', async () => { + const targetUserId = await createUser('target'); + const moderatorId = await createUser('moderator'); + const feedId = uniqueId('feed'); + cleanup.feeds.push(`user:${feedId}`); + await client.feeds + .feed('user', feedId) + .getOrCreate({ user_id: targetUserId }); + + // #region snippet docs="moderation/_default/02-quick-start/08-quick-start-feeds-v3-moderation.md" heading="Ban a User" tab="Node.js" index=1 + await client.moderation.ban({ + target_user_id: targetUserId, + banned_by_id: moderatorId, + reason: 'Repeated violations', + }); + // #endregion snippet + + expect((await getServerUser(client, targetUserId)).banned).toBe(true); + // "Banned users are prevented from ... creating activities". + const error: unknown = await client.feeds + .addActivity({ + type: 'post', + feeds: [`user:${feedId}`], + text: 'hello', + user_id: targetUserId, + }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(StreamError); + expect(error).toMatchObject({ code: 17 }); + expect(String(error)).toContain('Your account is currently banned'); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/03-moderation-engines/01-ai-llm-text-harm-detection-engine.test.ts b/__tests__/docs-snippets/pages/moderation/03-moderation-engines/01-ai-llm-text-harm-detection-engine.test.ts new file mode 100644 index 0000000..89fdbf7 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/03-moderation-engines/01-ai-llm-text-harm-detection-engine.test.ts @@ -0,0 +1,124 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { GetConfigResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +const DOCS = + 'moderation/_default/03-moderation-engines/01-ai-llm-text-harm-detection-engine.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + // The docs' "news-comments" policy and "user-42" comment author, test-owned. + const configKey = uniqueId('news-comments'); + const creatorId = uniqueId('user-42'); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('configures custom data keys as LLM context', async () => { + cleanup.add(() => client.moderation.deleteConfig({ key: configKey })); + + // #region snippet docs="moderation/_default/03-moderation-engines/01-ai-llm-text-harm-detection-engine.md" heading="Context from custom data" tab="Node.js" index=1 + // COPY: configKey="news-comments" + await client.moderation.upsertConfig({ + key: configKey, + llm_config: { + enabled: true, + app_context: + 'Comment sections under sports articles and videos. Trash talk about teams and performances is normal and allowed; attacks on people are not.', + context_custom_keys: ['content', 'thread'], + custom_keys_context_description: + 'content is the article or video the comment is posted under. thread.topLevel is the comment being replied to; thread.earlierReplies are earlier replies, oldest first.', + rules: [ + { + label: 'HARASSMENT', + description: + 'Insulting, demeaning or bullying another user, player, coach or referee as a person', + action: 'flag', + }, + { + label: 'VIOLENCE', + description: + "Threats, wishing injury or death on someone, or encouraging people to show up at someone's home", + action: 'remove', + }, + ], + }, + }); + // #endregion snippet + + const { config }: GetConfigResponse = await client.moderation.getConfig({ + key: configKey, + }); + expect(config?.llm_config).toMatchObject({ + enabled: true, + context_custom_keys: ['content', 'thread'], + custom_keys_context_description: expect.stringContaining( + 'thread.topLevel is the comment being replied to', + ) as unknown, + }); + expect( + config?.llm_config?.rules?.map(({ label, action }) => ({ + label, + action, + })), + ).toEqual([ + { label: 'HARASSMENT', action: 'flag' }, + { label: 'VIOLENCE', action: 'remove' }, + ]); + }); + + it('checks a comment with its context', async () => { + cleanup.users.push(creatorId); + await client.upsertUsers([{ id: creatorId }]); + const commentId = uniqueId('comment-123'); + const check = vi.spyOn(client.moderation, 'check'); + + // #region snippet docs="moderation/_default/03-moderation-engines/01-ai-llm-text-harm-detection-engine.md" heading="Context from custom data" tab="Node.js" index=2 + // COPY: commentId="comment-123", creatorId="user-42", configKey="news-comments" + await client.moderation.check({ + entity_type: 'comment', + entity_id: commentId, + entity_creator_id: creatorId, + config_key: configKey, + moderation_payload: { + texts: [ + "bet, see everyone at his place after the game, I'll bring friends", + ], + custom: { + schemaVersion: 1, + content: { + type: 'video', + title: 'Controversial late call by referee decides the game', + }, + thread: { + topLevel: { + id: 'comment-100', + user: { id: 'user-7' }, + text: 'That ref lives at 22 Oak Ridge Drive, someone should pay him a visit', + }, + earlierReplies: [ + { + id: 'comment-110', + user: { id: 'user-9' }, + text: 'He deserves whatever he gets', + }, + ], + }, + }, + }, + }); + // #endregion snippet + + // The LLM engine doesn't classify on the test app (even a direct threat comes back + // `keep`), so the docs' `remove` / `VIOLENCE` verdict can't be asserted here: check + // that the policy was applied and the check completed. + const response = await (check.mock.results[0].value as ReturnType< + typeof client.moderation.check + >); + expect(response.status).toBe('complete'); + expect(['keep', 'flag', 'remove']).toContain(response.recommended_action); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/04-configuration/01-policies.test.ts b/__tests__/docs-snippets/pages/moderation/04-configuration/01-policies.test.ts new file mode 100644 index 0000000..72e70de --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/04-configuration/01-policies.test.ts @@ -0,0 +1,169 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + GetConfigResponse, + QueryModerationConfigsResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { retry } from '../../../helpers/wait'; + +const DOCS = 'moderation/_default/04-configuration/01-policies.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + // The docs' "my_config" policy and "profanity_en" blocklist, test-owned. + const configKey = uniqueId('my-config'); + const profanityBlocklist = uniqueId('profanity'); + + /** `upsertConfig` retried while the new blocklist isn't known everywhere yet. */ + const isBlocklistMissing = (error: unknown) => + String(error).includes('Blocklist not found'); + + beforeAll(async () => { + await client.createBlockList({ + name: profanityBlocklist, + words: ['frobnicate'], + }); + cleanup.add(() => client.deleteBlockList({ name: profanityBlocklist })); + // The "Delete Config" snippet deletes it; this covers a failure before that. + cleanup.add(() => + client.moderation + .deleteConfig({ key: configKey }) + .catch((error: unknown) => { + if (!String(error).includes('not found')) throw error; + }), + ); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('upserts a config', async () => { + const upsertConfig = client.moderation.upsertConfig.bind(client.moderation); + vi.spyOn(client.moderation, 'upsertConfig').mockImplementation((request) => + retry(() => upsertConfig(request), { retryIf: isBlocklistMissing }), + ); + + // #region snippet docs="moderation/_default/04-configuration/01-policies.md" heading="Upsert Config" tab="Node.js" index=1 + // COPY: configKey="my_config", profanityBlocklist="profanity_en" + await client.moderation.upsertConfig({ + key: configKey, + ai_text_config: { + rules: [ + { label: 'SPAM', action: 'flag' }, + { + label: 'HATRED', + severity_rules: [ + { severity: 'low', action: 'flag' }, + { severity: 'high', action: 'remove' }, + ], + }, + ], + }, + block_list_config: { + rules: [{ name: profanityBlocklist, action: 'remove' }], + }, + ai_image_config: { + rules: [ + { label: 'Non-Explicit Nudity', action: 'flag', min_confidence: 50 }, + ], + }, + }); + // #endregion snippet + }); + + it('gets a config', async () => { + const getConfig = vi.spyOn(client.moderation, 'getConfig'); + + // #region snippet docs="moderation/_default/04-configuration/01-policies.md" heading="Get Config" tab="Node.js" index=1 + // COPY: configKey="my_config" + await client.moderation.getConfig({ key: configKey }); + // #endregion snippet + + const { config } = await (getConfig.mock.results[0] + .value as Promise); + expect(config?.key).toBe(configKey); + expect( + config?.ai_text_config?.rules?.map( + ({ label, action, severity_rules }) => ({ + label, + action, + severity_rules: severity_rules?.map(({ severity, action }) => ({ + severity, + action, + })), + }), + ), + ).toEqual([ + { label: 'SPAM', action: 'flag', severity_rules: [] }, + { + label: 'HATRED', + action: '', + severity_rules: [ + { severity: 'low', action: 'flag' }, + { severity: 'high', action: 'remove' }, + ], + }, + ]); + expect( + config?.block_list_config?.rules?.map(({ name, action }) => ({ + name, + action, + })), + ).toEqual([{ name: profanityBlocklist, action: 'remove' }]); + expect( + config?.ai_image_config?.rules?.map( + ({ label, action, min_confidence }) => ({ + label, + action, + min_confidence, + }), + ), + ).toEqual([ + { label: 'Non-Explicit Nudity', action: 'flag', min_confidence: 50 }, + ]); + }); + + it('queries configs', async () => { + const queryConfigs = vi.spyOn(client.moderation, 'queryModerationConfigs'); + + // #region snippet docs="moderation/_default/04-configuration/01-policies.md" heading="Query Configs" tab="Node.js" index=1 + await client.moderation.queryModerationConfigs({ + filter: {}, + sort: [{ field: 'created_at', direction: -1 }], + limit: 10, + }); + // #endregion snippet + + const { configs } = await (queryConfigs.mock.results[0] + .value as Promise); + expect(configs.length).toBeGreaterThan(0); + expect(configs.length).toBeLessThanOrEqual(10); + // Newest first: the config this file just created is among them. + const createdAt = configs.map(({ created_at }) => created_at.getTime()); + expect(createdAt).toEqual([...createdAt].sort((a, b) => b - a)); + expect(configs.map(({ key }) => key)).toContain(configKey); + }); + + it('deletes a config', async () => { + // #region snippet docs="moderation/_default/04-configuration/01-policies.md" heading="Delete Config" tab="Node.js" index=1 + // COPY: configKey="my_config" + await client.moderation.deleteConfig({ key: configKey }); + // #endregion snippet + + const error: unknown = await client.moderation + .getConfig({ key: configKey }) + .catch((e: unknown) => e); + expect(error).toMatchObject({ metadata: { responseCode: 404 } }); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/04-configuration/02-rules.test.ts b/__tests__/docs-snippets/pages/moderation/04-configuration/02-rules.test.ts new file mode 100644 index 0000000..dded07c --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/04-configuration/02-rules.test.ts @@ -0,0 +1,167 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { + GetModerationRuleResponse, + QueryModerationRulesResponse, + UpsertModerationRuleResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +const DOCS = 'moderation/_default/04-configuration/02-rules.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + // The docs' "chat:messaging" config key, test-owned: an enabled rule on the app's + // own config would act on every chat message of the shared app. + const configKey = `chat:messaging:${uniqueId('rules')}`; + // The id of the rule the "Upsert Rule" snippet creates ("rule_id" in the docs). + let ruleId = ''; + + /** Deletes a rule unless a snippet already did. */ + const deleteRule = (id: string) => + client.moderation + .deleteModerationRule({ id }) + .then(() => undefined) + .catch((error: unknown) => { + if (!String(error).includes('not found')) throw error; + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('upserts a rule', async () => { + // The docs' rule name, test-owned (rules are app-wide). + const ruleName = uniqueId('ban-on-severe-harassment'); + const upsertRule = vi.spyOn(client.moderation, 'upsertModerationRule'); + + // #region snippet docs="moderation/_default/04-configuration/02-rules.md" heading="Upsert Rule" tab="Node.js" index=1 + // COPY: ruleName="ban_on_severe_harassment", configKey="chat:messaging" + await client.moderation.upsertModerationRule({ + name: ruleName, + rule_type: 'user', + config_keys: [configKey], + logic: 'AND', + action: { + type: 'ban_user', + ban_options: { duration: 3600, reason: 'Severe harassment' }, + }, + conditions: [ + { + type: 'text_rule', + text_rule_params: { harm_labels: ['HATRED'], severity: 'critical' }, + }, + ], + enabled: true, + description: 'Auto-ban users for critical harassment', + }); + // #endregion snippet + + const { rule } = await (upsertRule.mock.results[0] + .value as Promise); + if (!rule) throw new Error('upsertModerationRule returned no rule'); + ruleId = rule.id; + cleanup.add(() => deleteRule(rule.id)); + expect(rule).toMatchObject({ + name: ruleName, + rule_type: 'user', + config_keys: [configKey], + logic: 'AND', + enabled: true, + description: 'Auto-ban users for critical harassment', + action: { + type: 'ban_user', + ban_options: { duration: 3600, reason: 'Severe harassment' }, + }, + }); + expect(rule.conditions).toMatchObject([ + { + type: 'text_rule', + text_rule_params: { harm_labels: ['HATRED'], severity: 'critical' }, + }, + ]); + }); + + it('gets a rule', async () => { + expect(ruleId).not.toBe(''); + const getRule = vi.spyOn(client.moderation, 'getModerationRule'); + + // #region snippet docs="moderation/_default/04-configuration/02-rules.md" heading="Get Rule" tab="Node.js" index=1 + // COPY: ruleId="rule_id" + await client.moderation.getModerationRule({ id: ruleId }); + // #endregion snippet + + const { rule } = await (getRule.mock.results[0] + .value as Promise); + expect(rule).toMatchObject({ + id: ruleId, + rule_type: 'user', + config_keys: [configKey], + enabled: true, + }); + }); + + it('deletes a rule', async () => { + expect(ruleId).not.toBe(''); + const before = await client.moderation.queryModerationRules({ + filter: { id: ruleId }, + }); + expect(before.rules.map(({ id }) => id)).toEqual([ruleId]); + + // #region snippet docs="moderation/_default/04-configuration/02-rules.md" heading="Delete Rule" tab="Node.js" index=1 + // COPY: ruleId="rule_id" + await client.moderation.deleteModerationRule({ id: ruleId }); + // #endregion snippet + + const { rules } = await client.moderation.queryModerationRules({ + filter: { id: ruleId }, + }); + expect(rules).toEqual([]); + await expect( + client.moderation.getModerationRule({ id: ruleId }), + ).rejects.toThrow(); + }); + + it('queries rules', async () => { + // Two enabled rules, created in order: the docs query matches every enabled rule + // of the app, newest first, so the second one must come before the first. + const created: string[] = []; + for (const name of [uniqueId('older-rule'), uniqueId('newer-rule')]) { + const { rule } = await client.moderation.upsertModerationRule({ + name, + rule_type: 'content', + config_keys: [configKey], + action: { type: 'flag_content' }, + conditions: [ + { + type: 'text_content', + text_content_params: { harm_labels: ['SPAM'] }, + }, + ], + enabled: true, + }); + if (!rule) throw new Error('upsertModerationRule returned no rule'); + cleanup.add(() => deleteRule(rule.id)); + created.push(rule.id); + } + const queryRules = vi.spyOn(client.moderation, 'queryModerationRules'); + + // #region snippet docs="moderation/_default/04-configuration/02-rules.md" heading="Query Rules" tab="Node.js" index=1 + await client.moderation.queryModerationRules({ + filter: { enabled: true }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 10, + }); + // #endregion snippet + + const { rules } = await (queryRules.mock.results[0] + .value as Promise); + expect(rules.length).toBeLessThanOrEqual(10); + expect(rules.every((rule) => rule.enabled)).toBe(true); + const ours = rules + .map((rule) => rule.id) + .filter((id) => created.includes(id)); + expect(ours).toEqual([created[1], created[0]]); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/04-configuration/04-filters.test.ts b/__tests__/docs-snippets/pages/moderation/04-configuration/04-filters.test.ts new file mode 100644 index 0000000..0907f49 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/04-configuration/04-filters.test.ts @@ -0,0 +1,253 @@ +import { + afterAll, + afterEach, + 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 { retry } from '../../../helpers/wait'; + +const DOCS = 'moderation/_default/04-configuration/04-filters.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + // The docs' blocklist names, test-owned (blocklists are app-wide). + const profanityCustom = uniqueId('profanity-custom'); + const blockedDomains = uniqueId('blocked-domains'); + const phoneNumbers = uniqueId('phone-numbers'); + const trustedDomains = uniqueId('trusted-domains'); + const approvedEmails = uniqueId('approved-emails'); + // The docs' "chat:messaging" policy, test-owned (a channel-level chat policy). + const configKey = `chat:messaging:${uniqueId('filters')}`; + const creatorId = uniqueId('creator'); + + const isNotFound = (error: unknown) => + error instanceof StreamError && error.metadata.responseCode === 404; + + /** `getBlockList` retried until the blocklist matches `check`. */ + const waitForBlockList = ( + name: string, + check: (words: string[]) => boolean, + ) => + retry(async () => { + const { blocklist } = await client.getBlockList({ name }); + if (!blocklist || !check(blocklist.words)) { + throw new Error(`blocklist ${name} not updated yet`); + } + return blocklist; + }); + + beforeAll(async () => { + await client.upsertUsers([{ id: creatorId }]); + cleanup.users.push(creatorId); + for (const name of [ + profanityCustom, + blockedDomains, + phoneNumbers, + trustedDomains, + approvedEmails, + ]) { + // "Delete a Blocklist" deletes `profanityCustom`; this covers a failure before that. + cleanup.add(() => + client + .deleteBlockList({ name }) + .then(() => undefined) + .catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }), + ); + } + cleanup.add(() => client.moderation.deleteConfig({ key: configKey })); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates blocklists', async () => { + // #region snippet docs="moderation/_default/04-configuration/04-filters.md" heading="Create a Blocklist" tab="Node.js" index=1 + // COPY: profanityCustom="profanity_custom", blockedDomains="blocked_domains", phoneNumbers="phone_numbers", trustedDomains="trusted_domains", approvedEmails="approved_emails" + // Create a word blocklist + await client.createBlockList({ + name: profanityCustom, + words: ['badword1', 'badword2', 'offensive'], + type: 'word', + }); + + // Create a domain blocklist + await client.createBlockList({ + name: blockedDomains, + words: ['spam-site.com', 'phishing.net'], + type: 'domain', + }); + + // Create a regex filter + await client.createBlockList({ + name: phoneNumbers, + words: ['\\b\\d{3}[-.]?\\d{3}[-.]?\\d{4}\\b'], + type: 'regex', + }); + + // Create a domain allowlist (only these domains are permitted) + await client.createBlockList({ + name: trustedDomains, + words: ['myapp.com', 'getstream.io'], + type: 'domain_allowlist', + }); + + // Create an email allowlist + await client.createBlockList({ + name: approvedEmails, + words: ['support@myapp.com', 'info@myapp.com'], + type: 'email_allowlist', + }); + // #endregion snippet + + const expected: Array<[string, string, string[]]> = [ + [profanityCustom, 'word', ['badword1', 'badword2', 'offensive']], + [blockedDomains, 'domain', ['spam-site.com', 'phishing.net']], + [phoneNumbers, 'regex', ['\\b\\d{3}[-.]?\\d{3}[-.]?\\d{4}\\b']], + [trustedDomains, 'domain_allowlist', ['myapp.com', 'getstream.io']], + [ + approvedEmails, + 'email_allowlist', + ['support@myapp.com', 'info@myapp.com'], + ], + ]; + for (const [name, type, words] of expected) { + const blocklist = await waitForBlockList(name, () => true); + expect(blocklist.type).toBe(type); + expect([...blocklist.words].sort()).toEqual([...words].sort()); + } + }); + + it('updates a blocklist', async () => { + // #region snippet docs="moderation/_default/04-configuration/04-filters.md" heading="Update a Blocklist" tab="Node.js" index=1 + // COPY: profanityCustom="profanity_custom" + await client.updateBlockList({ + name: profanityCustom, + words: ['badword1', 'badword2', 'offensive', 'newword'], + is_leet_check_enabled: true, + is_plural_check_enabled: true, + }); + // #endregion snippet + + const blocklist = await waitForBlockList(profanityCustom, (words) => + words.includes('newword'), + ); + expect([...blocklist.words].sort()).toEqual([ + 'badword1', + 'badword2', + 'newword', + 'offensive', + ]); + expect(blocklist.is_leet_check_enabled).toBe(true); + expect(blocklist.is_plural_check_enabled).toBe(true); + }); + + it('lists blocklists', async () => { + // #region snippet docs="moderation/_default/04-configuration/04-filters.md" heading="List Blocklists" tab="Node.js" index=1 + const response = await client.listBlockLists(); + // #endregion snippet + + expect(response.blocklists.map(({ name }) => name)).toEqual( + expect.arrayContaining([ + profanityCustom, + blockedDomains, + phoneNumbers, + trustedDomains, + approvedEmails, + ]), + ); + }); + + it('gets a blocklist', async () => { + // #region snippet docs="moderation/_default/04-configuration/04-filters.md" heading="Get a Blocklist" tab="Node.js" index=1 + // COPY: profanityCustom="profanity_custom" + const response = await client.getBlockList({ name: profanityCustom }); + // #endregion snippet + + expect(response.blocklist?.name).toBe(profanityCustom); + expect(response.blocklist?.type).toBe('word'); + expect(response.blocklist?.words).toContain('newword'); + }); + + it('attaches blocklists to a policy', async () => { + // Right after `createBlockList`, `upsertConfig` can fail with "Blocklist not found". + const upsertConfig = client.moderation.upsertConfig.bind(client.moderation); + vi.spyOn(client.moderation, 'upsertConfig').mockImplementation((request) => + retry(() => upsertConfig(request), { + retryIf: (error) => String(error).includes('Blocklist not found'), + }), + ); + + // #region snippet docs="moderation/_default/04-configuration/04-filters.md" heading="Attach a Blocklist to a Policy" tab="Node.js" index=1 + // COPY: configKey="chat:messaging", profanityCustom="profanity_custom", blockedDomains="blocked_domains", phoneNumbers="phone_numbers" + await client.moderation.upsertConfig({ + key: configKey, + block_list_config: { + rules: [ + { name: profanityCustom, action: 'remove' }, + { name: blockedDomains, action: 'flag' }, + { name: phoneNumbers, action: 'flag' }, + ], + }, + }); + // #endregion snippet + + const { config } = await client.moderation.getConfig({ key: configKey }); + expect( + config?.block_list_config?.rules?.map(({ name, action }) => ({ + name, + action, + })), + ).toEqual([ + { name: profanityCustom, action: 'remove' }, + { name: blockedDomains, action: 'flag' }, + { name: phoneNumbers, action: 'flag' }, + ]); + + // The policy applies: a word from the blocklist is removed, a phone number flagged. + const check = (text: string) => + client.moderation.check({ + entity_type: 'stream:chat:v1:message', + entity_id: uniqueId('message'), + entity_creator_id: creatorId, + config_key: configKey, + moderation_payload: { texts: [text] }, + }); + const removed = await retry(async () => { + const response = await check('this is badword1'); + if (response.recommended_action !== 'remove') { + throw new Error(`not removed yet: ${response.recommended_action}`); + } + return response; + }); + expect(removed.recommended_action).toBe('remove'); + expect((await check('call me at 555-123-4567')).recommended_action).toBe( + 'flag', + ); + expect((await check('hello there')).recommended_action).toBe('keep'); + }); + + it('deletes a blocklist', async () => { + // #region snippet docs="moderation/_default/04-configuration/04-filters.md" heading="Delete a Blocklist" tab="Node.js" index=1 + // COPY: profanityCustom="profanity_custom" + await client.deleteBlockList({ name: profanityCustom }); + // #endregion snippet + + await retry(async () => { + const error: unknown = await client + .getBlockList({ name: profanityCustom }) + .then(() => undefined) + .catch((e: unknown) => e); + if (!isNotFound(error)) throw new Error('blocklist still there'); + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/04-configuration/05-evaluations.test.ts b/__tests__/docs-snippets/pages/moderation/04-configuration/05-evaluations.test.ts new file mode 100644 index 0000000..0d0753c --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/04-configuration/05-evaluations.test.ts @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, 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 { retry } from '../../../helpers/wait'; + +const DOCS = 'moderation/_default/04-configuration/05-evaluations.md'; + +const TERMINAL = ['completed', 'error', 'cancelled']; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + // The docs' "chat:messaging" policy, test-owned (a channel-level chat policy). + const configKey = `chat:messaging:${uniqueId('evals')}`; + // The docs' "Hate-speech sample" set name (names are unique per app). + const setName = uniqueId('hate-speech-sample'); + // A set with pasted rows, the docs' "pts_..." set for get / run / delete. + const rowsSetName = uniqueId('evals-rows'); + const rowTexts = ['hello there', 'buy cheap pills now']; + let setId = ''; + let runId = ''; + // Evaluation sets have server-assigned ids and aren't covered by the leak check. + const createdSetIds: string[] = []; + + const isNotFound = (error: unknown) => + error instanceof StreamError && error.metadata.responseCode === 404; + + beforeAll(async () => { + await client.moderation.upsertConfig({ + key: configKey, + ai_text_config: { rules: [{ label: 'SPAM', action: 'flag' }] }, + }); + cleanup.add(() => client.moderation.deleteConfig({ key: configKey })); + cleanup.add(async () => { + for (const id of createdSetIds) { + await client.moderation + .deletePolicyTestSet({ id }) + .catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); + } + }); + + const { set } = await client.moderation.createPolicyTestSet({ + name: rowsSetName, + config_key: configKey, + rows: rowTexts.map((text) => ({ text })), + }); + if (!set) throw new Error('evaluation set not created'); + createdSetIds.push(set.id); + setId = set.id; + }); + + afterAll(async () => { + await cleanup.run(); + // Leak check for evaluation sets: none of this file's sets may be left. + const { sets } = await client.moderation.listPolicyTestSets(); + expect(sets.filter((s) => [setName, rowsSetName].includes(s.name))).toEqual( + [], + ); + }); + + it('creates an evaluation set', async () => { + // #region snippet docs="moderation/_default/04-configuration/05-evaluations.md" heading="Create an evaluation set" tab="Node.js" index=1 + // COPY: setName="Hate-speech sample", configKey="chat:messaging" + const response = await client.moderation.createPolicyTestSet({ + name: setName, + config_key: configKey, + seed: { labels: [], limit: 100 }, + }); + // #endregion snippet + + const { set } = response; + if (set) createdSetIds.push(set.id); + expect(set).toMatchObject({ name: setName, config_key: configKey }); + // Seeded from the newest moderated production content. + expect(set?.rows?.length).toBeGreaterThan(0); + expect(set?.rows?.length).toBeLessThanOrEqual(100); + expect(set?.row_count).toBe(set?.rows?.length); + }); + + it('gets an evaluation set', async () => { + // #region snippet docs="moderation/_default/04-configuration/05-evaluations.md" heading="Get an evaluation set" tab="Node.js" index=1 + // COPY: setId="pts_..." + const response = await client.moderation.getPolicyTestSet({ id: setId }); + // #endregion snippet + + expect(response.set).toMatchObject({ + id: setId, + name: rowsSetName, + config_key: configKey, + }); + expect(response.set?.rows?.map((row) => row.text)).toEqual(rowTexts); + // No run yet: no baseline. + expect(response.recent_runs ?? []).toEqual([]); + expect(response.baseline_run_id).toBeUndefined(); + }); + + it('starts a run', async () => { + // #region snippet docs="moderation/_default/04-configuration/05-evaluations.md" heading="Start a run" tab="Node.js" index=1 + // COPY: setId="pts_..." + const response = await client.moderation.startPolicyTestRun({ id: setId }); + // #endregion snippet + + expect(response.run).toMatchObject({ + set_id: setId, + config_key: configKey, + rows_total: rowTexts.length, + }); + expect(['pending', 'running', ...TERMINAL]).toContain(response.run?.status); + runId = response.run?.id ?? ''; + expect(runId).not.toBe(''); + }); + + it('gets a run and its results', async () => { + // Poll until the run is terminal, as the docs describe. + await retry(async () => { + const { run } = await client.moderation.getPolicyTestRun({ id: runId }); + if (!TERMINAL.includes(run?.status ?? '')) { + throw new Error(`run ${runId} still ${run?.status}`); + } + }); + + // #region snippet docs="moderation/_default/04-configuration/05-evaluations.md" heading="Get a run (and results)" tab="Node.js" index=1 + // COPY: runId="ptr_..." + const response = await client.moderation.getPolicyTestRun({ id: runId }); + // #endregion snippet + + expect(response.run).toMatchObject({ + id: runId, + set_id: setId, + status: 'completed', + rows_total: rowTexts.length, + rows_completed: rowTexts.length, + }); + expect(response.results?.map((r) => [r.row_index, r.message_text])).toEqual( + rowTexts.map((text, i) => [i, text]), + ); + // Rows without expectations on the baseline run are unscored. + expect(response.results?.every((r) => !r.scored)).toBe(true); + + // The first completed run is the set's baseline. + const { baseline_run_id } = await client.moderation.getPolicyTestSet({ + id: setId, + }); + expect(baseline_run_id).toBe(runId); + }); + + // The generated `listPolicyTestSets()` takes no arguments (TS2554), so + // `limit` isn't sent: the API returns its default page size. + it('lists evaluation sets', async () => { + // #region snippet docs="moderation/_default/04-configuration/05-evaluations.md" heading="List evaluation sets" tab="Node.js" index=1 + const response = await client.moderation.listPolicyTestSets({ limit: 50 }); + // #endregion snippet + + const listed = response.sets.find((set) => set.id === setId); + expect(listed).toMatchObject({ name: rowsSetName, config_key: configKey }); + // `last_run` saves a request per set for the last-run status. + expect(listed?.last_run).toMatchObject({ id: runId, status: 'completed' }); + }); + + it('deletes an evaluation set', async () => { + // #region snippet docs="moderation/_default/04-configuration/05-evaluations.md" heading="Delete an evaluation set" tab="Node.js" index=1 + // COPY: setId="pts_..." + await client.moderation.deletePolicyTestSet({ id: setId }); + // #endregion snippet + + await expect( + client.moderation.getPolicyTestSet({ id: setId }), + ).rejects.toMatchObject({ code: 16, metadata: { responseCode: 404 } }); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/04-configuration/05-flooding.test.ts b/__tests__/docs-snippets/pages/moderation/04-configuration/05-flooding.test.ts new file mode 100644 index 0000000..e33e65e --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/04-configuration/05-flooding.test.ts @@ -0,0 +1,88 @@ +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 = 'moderation/_default/04-configuration/05-flooding.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + // The docs' app-wide "chat:messaging" policy, test-owned. + const configKey = uniqueId('flood-config'); + const sender = uniqueId('flooder'); + + beforeAll(async () => { + cleanup.users.push(sender); + await client.upsertUsers([{ id: sender }]); + cleanup.add(() => client.moderation.deleteConfig({ key: configKey })); + }); + + afterAll(() => cleanup.run()); + + it('configures flood detection', async () => { + // #region snippet docs="moderation/_default/04-configuration/05-flooding.md" heading="Configuration" tab="Node.js" index=1 + // COPY: configKey="chat:messaging" + await client.moderation.upsertConfig({ + key: configKey, + flood_config: { + identical: { + enabled: true, + threshold: 5, + time_window: '30m', + action: 'flag', + }, + similar: { + enabled: true, + threshold: 5, + time_window: '30m', + similarity_distance: 5, + action: 'remove', + }, + allowlist: ['gg', 'well played'], + }, + }); + // #endregion snippet + + const { config } = await client.moderation.getConfig({ key: configKey }); + expect(config?.flood_config).toMatchObject({ + identical: { + enabled: true, + threshold: 5, + time_window: '30m', + action: 'flag', + }, + similar: { + enabled: true, + threshold: 5, + time_window: '30m', + similarity_distance: 5, + action: 'remove', + }, + allowlist: ['gg', 'well played'], + }); + + // The policy applies: the fifth identical message from one user is flagged. + const check = (text: string) => + client.moderation.check({ + entity_type: 'stream:chat:v1:message', + entity_id: uniqueId('message'), + entity_creator_id: sender, + config_key: configKey, + moderation_payload: { texts: [text] }, + }); + const actions: string[] = []; + for (let i = 0; i < 5; i++) { + const response = await check('buy my stuff at the shop now'); + actions.push(response.recommended_action); + } + expect(actions).toEqual(['keep', 'keep', 'keep', 'keep', 'flag']); + // Allowlisted phrases are never flood-flagged. + const allowlisted: string[] = []; + for (let i = 0; i < 6; i++) { + const response = await check('well played'); + allowlisted.push(response.recommended_action); + } + expect(allowlisted.every((action) => action === 'keep')).toBe(true); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/05-content-moderation/01-check.test.ts b/__tests__/docs-snippets/pages/moderation/05-content-moderation/01-check.test.ts new file mode 100644 index 0000000..a186fa4 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/05-content-moderation/01-check.test.ts @@ -0,0 +1,329 @@ +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 { retry } from '../../../helpers/wait'; + +const DOCS = 'moderation/_default/05-content-moderation/01-check.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + // The docs' "config_key" / "catalog-v1" policies, test-owned: a blocklist that flags + // a word of the "API Usage" text and of the comment-context text. + const configKey = uniqueId('check-config'); + const blocklist = uniqueId('check-words'); + const creator = uniqueId('creator'); + const driverDan = uniqueId('driver-dan'); + const listingUser = uniqueId('listing-user'); + const channelId = uniqueId('check-channel'); + + beforeAll(async () => { + cleanup.users.push(creator, driverDan, listingUser); + await client.upsertUsers([ + { id: creator }, + { id: driverDan }, + { id: listingUser }, + ]); + await client.createBlockList({ + name: blocklist, + words: ['bullshit', 'cyclists'], + }); + cleanup.add(() => client.deleteBlockList({ name: blocklist })); + // A new blocklist isn't known to the moderation API right away ("Blocklist not found"). + await retry( + () => + client.moderation.upsertConfig({ + key: configKey, + block_list_config: { + rules: [{ name: blocklist, action: 'flag' }], + }, + }), + { retryIf: (error) => String(error).includes('Blocklist not found') }, + ); + cleanup.add(() => client.moderation.deleteConfig({ key: configKey })); + // A new policy can take a moment to apply. + await retry(async () => { + const { recommended_action } = await client.moderation.check({ + entity_type: 'stream:chat:v1:message', + entity_id: uniqueId('warmup'), + entity_creator_id: creator, + config_key: configKey, + moderation_payload: { texts: ['bullshit'] }, + }); + if (recommended_action !== 'flag') { + throw new Error(`recommended_action: ${recommended_action}`); + } + }); + + const channel = client.chat.channel('messaging', channelId); + await channel.getOrCreate({ data: { created_by_id: creator } }); + cleanup.channels.push(`messaging:${channelId}`); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('checks an entity', async () => { + const entityId = uniqueId('entity'); + + // #region snippet docs="moderation/_default/05-content-moderation/01-check.md" heading="API Usage" tab="Node.js" index=1 + // COPY: entityId="entity_id", creator="entity_creator_id", configKey="config_key" + const response = await client.moderation.check({ + entity_type: 'entity_type', + entity_id: entityId, + entity_creator_id: creator, + moderation_payload: { + texts: ['this is bullshit', 'f*ck you as***le'], + images: ['example.com/test.jpg'], + }, + config_key: configKey, + }); + // #endregion snippet + + expect(response.recommended_action).toBe('flag'); + expect(['complete', 'partial']).toContain(response.status); + expect(response.item).toMatchObject({ + entity_id: entityId, + entity_type: 'entity_type', + }); + }); + + it('sends comment context for the review queue', async () => { + const commentId = uniqueId('a-r7'); + const check = vi.spyOn(client.moderation, 'check'); + + // #region snippet docs="moderation/_default/05-content-moderation/01-check.md" heading="Comment context in the review queue" tab="Node.js" index=1 + // COPY: commentId="a_r7", driverDan="driver_dan", configKey="config_key" + await client.moderation.check({ + entity_type: 'comment', + entity_id: commentId, + entity_creator_id: driverDan, + moderation_payload: { + texts: ['Cyclists can all go back where they came from'], + custom: { + schemaVersion: 1, + depth: 1, + content: { + id: 'news-48213', + type: 'article', + title: 'City council approves new bike lanes downtown', + subtitle: 'Local news', + tags: [ + { key: 'section', label: 'Section', values: ['Transport'] }, + { + key: 'topics', + label: 'Topics', + values: ['City council', 'Cycling'], + }, + ], + description: + 'The plan adds 12 km of protected lanes over two years...', + url: 'https://example.com/news/bike-lanes', + imageUrl: 'https://example.com/news/bike-lanes.jpg', + }, + thread: { + topLevel: { + id: 'a_top', + user: { id: 'cyclist_jen', name: 'Jen' }, + text: 'Finally. I have been waiting for this for years.', + createdAt: '2026-10-06T08:12:00Z', + }, + earlierReplyCount: 7, + earlierReplies: [ + { + id: 'a_r6', + user: { id: driverDan, name: 'Dan' }, + text: 'Good luck finding parking now.', + createdAt: '2026-10-06T08:40:00Z', + }, + ], + }, + }, + }, + config_key: configKey, + }); + // #endregion snippet + + const response = await (check.mock.results[0].value as ReturnType< + typeof client.moderation.check + >); + expect(response.recommended_action).toBe('flag'); + + // The review queue item keeps the context in moderation_payload.custom. + const item = await retry(async () => { + const { items } = await client.moderation.queryReviewQueue({ + filter: { entity_id: commentId }, + }); + if (items.length !== 1) throw new Error(`items: ${items.length}`); + return items[0]; + }); + expect(item.entity_type).toBe('comment'); + expect(item.entity_creator_id).toBe(driverDan); + expect(item.moderation_payload?.custom).toMatchObject({ + schemaVersion: 1, + depth: 1, + content: { id: 'news-48213', type: 'article' }, + thread: { + topLevel: { id: 'a_top' }, + earlierReplyCount: 7, + earlierReplies: [{ id: 'a_r6', user: { id: driverDan } }], + }, + }); + }); + + // The generated analyze() sends JSON, but the API only accepts multipart/form-data with + // a JSON `payload` part (a raw multipart request with the same fields returns 201 and + // `texts.caption: { id: 'cap_42', action: 'keep' }`). + it.skip('BLOCKED: SDK issue: V2Analyze failed with error: "Content-Type must be multipart/form-data, got "application/json""', async () => { + const listingId = uniqueId('listing'); + const catalogConfig = configKey; + + // #region snippet docs="moderation/_default/05-content-moderation/01-check.md" heading="Calling Analyze" tab="Node.js" index=1 + // COPY: listingId="listing_42", listingUser="user_42", catalogConfig="catalog-v1" + const response = await client.moderation.analyze({ + entity_type: 'listing', + entity_id: listingId, + entity_creator_id: listingUser, + config_key: catalogConfig, + content_ids: { caption: 'cap_42' }, + texts: { caption: 'Vintage watch, mint condition' }, + }); + // #endregion snippet + + expect(response.status).toBe('complete'); + expect(response.texts?.caption).toMatchObject({ + id: 'cap_42', + action: 'keep', + }); + }); + + it('adds custom flags to a chat message', async () => { + const message = await sendServerMessage(client, `messaging:${channelId}`, { + text: 'A message with an image', + user_id: creator, + }); + const messageId = message.id; + const customCheck = vi.spyOn(client.moderation, 'customCheck'); + + // #region snippet docs="moderation/_default/05-content-moderation/01-check.md" heading="Add Custom Flags to a Chat Message" tab="Node.js" index=1 + // COPY: creator="user_id" + await client.moderation.customCheck({ + entity_type: 'stream:chat:v1:message', + entity_id: messageId, + entity_creator_id: creator, + flags: [ + { + type: 'custom_check_image', + reason: 'Image was NSFW', + labels: ['NSFW'], + }, + { + type: 'custom_check_text', + reason: 'Text was harmful', + labels: ['harmful'], + }, + { + type: 'custom_check_video', + reason: 'Video contains copyright material', + labels: ['copyright-violation'], + }, + ], + }); + // #endregion snippet + + const response = await (customCheck.mock.results[0].value as ReturnType< + typeof client.moderation.customCheck + >); + expect(typeof response.id).toBe('string'); + expect(response.item?.entity_id).toBe(messageId); + // The response item has no flags yet: they reach the review queue item a moment later. + const item = await retry(async () => { + const { items } = await client.moderation.queryReviewQueue({ + filter: { entity_id: messageId }, + }); + if (items.length !== 1 || items[0].flags.length !== 3) { + throw new Error(`items: ${items.length}`); + } + return items[0]; + }); + expect(item.entity_type).toBe('stream:chat:v1:message'); + expect( + item.flags.map(({ type, reason, labels }) => ({ type, reason, labels })), + ).toEqual( + expect.arrayContaining([ + { + type: 'custom_check_image', + reason: 'Image was NSFW', + labels: ['NSFW'], + }, + { + type: 'custom_check_text', + reason: 'Text was harmful', + labels: ['harmful'], + }, + { + type: 'custom_check_video', + reason: 'Video contains copyright material', + labels: ['copyright-violation'], + }, + ]), + ); + }); + + it('adds custom flags to custom content', async () => { + const entityId = uniqueId('custom-entity'); + const customCheck = vi.spyOn(client.moderation, 'customCheck'); + + // #region snippet docs="moderation/_default/05-content-moderation/01-check.md" heading="Add Custom Check Flags to the Custom Content" tab="Node.js" index=1 + // COPY: entityId="entity_id", creator="entity_creator_id" + await client.moderation.customCheck({ + entity_type: 'entity_type', + entity_id: entityId, + entity_creator_id: creator, + moderation_payload: { + texts: ['offensive'], + }, + flags: [ + { + type: 'custom_check_text', + reason: 'Text was offensive', + custom: { + explaination: + 'custom is a nullable field allowing you to attach a custom object to the flag for your specific use cases or requirements', + }, + }, + ], + }); + // #endregion snippet + + const response = await (customCheck.mock.results[0].value as ReturnType< + typeof client.moderation.customCheck + >); + expect(response.item).toMatchObject({ + entity_id: entityId, + entity_type: 'entity_type', + entity_creator_id: creator, + }); + const item = await retry(async () => { + const { items } = await client.moderation.queryReviewQueue({ + filter: { entity_id: entityId }, + }); + if (items.length !== 1) throw new Error(`items: ${items.length}`); + return items[0]; + }); + expect(item.flags[0]).toMatchObject({ + type: 'custom_check_text', + reason: 'Text was offensive', + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/05-content-moderation/02-flag-mute-ban.test.ts b/__tests__/docs-snippets/pages/moderation/05-content-moderation/02-flag-mute-ban.test.ts new file mode 100644 index 0000000..a7c009f --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/05-content-moderation/02-flag-mute-ban.test.ts @@ -0,0 +1,423 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { StreamClient, UserResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { + getServerUser, + sendServerMessage, + waitForReviewItem, +} from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; + +const DOCS = 'moderation/_default/05-content-moderation/02-flag-mute-ban.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const moderatorId = uniqueId('moderator'); + const channelId = uniqueId('mod-channel'); + const cid = `messaging:${channelId}`; + + /** A fresh `uniqueId` user, removed in cleanup. */ + const createUser = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + return id; + }; + + /** A message by `userId` in the test channel. */ + const sendMessage = async (userId: string) => { + const message = await sendServerMessage(client, cid, { + text: 'buy cheap followers now', + user_id: userId, + }); + return message.id; + }; + + beforeAll(async () => { + cleanup.users.push(moderatorId); + await client.upsertUsers([{ id: moderatorId, role: 'admin' }]); + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: moderatorId } }); + cleanup.channels.push(cid); + }); + + afterAll(() => cleanup.run()); + + it('flags a message', async () => { + const creatorId = await createUser('creator'); + const messageId = await sendMessage(creatorId); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Flag Content" tab="Node.js" index=1 + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: messageId, + entity_creator_id: creatorId, + reason: 'spam', + custom: { user_comment: 'This user is spamming the channel' }, + user_id: moderatorId, + }); + // #endregion snippet + + const item = await waitForReviewItem(client, messageId); + expect(item).toMatchObject({ + entity_type: 'stream:chat:v1:message', + entity_creator_id: creatorId, + }); + expect(item.flags[0]).toMatchObject({ + reason: 'spam', + user_id: moderatorId, + custom: { user_comment: 'This user is spamming the channel' }, + }); + }); + + it('flags a user', async () => { + const targetUserId = await createUser('flagged-user'); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Flag a User" tab="Node.js" index=1 + await client.moderation.flag({ + entity_type: 'stream:user', + entity_id: targetUserId, + reason: 'harassment', + user_id: moderatorId, + }); + // #endregion snippet + + const item = await waitForReviewItem(client, targetUserId); + expect(item.entity_type).toBe('stream:user'); + expect(item.flags[0]).toMatchObject({ + reason: 'harassment', + user_id: moderatorId, + }); + }); + + it('queries moderation flags', async () => { + // The filter matches every message flag of the app: flag a fresh message just + // before, so it is the newest one. + const creatorId = await createUser('flag-query-creator'); + const messageId = await sendMessage(creatorId); + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: messageId, + entity_creator_id: creatorId, + reason: 'spam', + user_id: moderatorId, + }); + await waitForReviewItem(client, messageId); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Query Moderation Flags" tab="Node.js" index=1 + const response = await client.moderation.queryModerationFlags({ + filter: { entity_type: 'stream:chat:v1:message' }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 20, + }); + // #endregion snippet + + expect(response.flags.length).toBeGreaterThan(0); + expect(response.flags.length).toBeLessThanOrEqual(20); + expect(response.flags[0]).toMatchObject({ + entity_id: messageId, + reason: 'spam', + user_id: moderatorId, + }); + expect( + response.flags.every((f) => f.entity_type === 'stream:chat:v1:message'), + ).toBe(true); + }); + + it('mutes a user', async () => { + const mutingUserId = await createUser('muting-user'); + const mutedUser = await createUser('user-to-mute'); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Mute User" tab="Node.js" index=1 + // COPY: mutedUser="user_to_mute" + await client.moderation.mute({ + target_ids: [mutedUser], + user_id: mutingUserId, + timeout: 60, + }); + // #endregion snippet + + const { mutes } = await getServerUser(client, mutingUserId); + expect(mutes.map((m) => m.target?.id)).toEqual([mutedUser]); + // 60 minutes + expect( + (mutes[0].expires?.getTime() ?? 0) - mutes[0].created_at.getTime(), + ).toBe(60 * 60 * 1000); + }); + + it('unmutes a user', async () => { + const unmutingUserId = await createUser('unmuting-user'); + const unmutedUser = await createUser('user-to-unmute'); + await client.moderation.mute({ + target_ids: [unmutedUser], + user_id: unmutingUserId, + }); + const before = await getServerUser(client, unmutingUserId); + expect(before.mutes.map((m) => m.target?.id)).toEqual([unmutedUser]); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Unmute User" tab="Node.js" index=1 + // COPY: unmutedUser="user_to_unmute" + await client.moderation.unmute({ + target_ids: [unmutedUser], + user_id: unmutingUserId, + }); + // #endregion snippet + + const after = await getServerUser(client, unmutingUserId); + expect(after.mutes).toEqual([]); + }); + + it('bans a user from the app', async () => { + const banTarget = await createUser('user-to-ban'); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Ban User" tab="Node.js" index=1 + // COPY: banTarget="user_to_ban" + await client.moderation.ban({ + target_user_id: banTarget, + banned_by_id: moderatorId, + reason: 'Repeated spam', + timeout: 1440, + }); + // #endregion snippet + + const user = await getServerUser(client, banTarget); + expect(user.banned).toBe(true); + const { bans } = await client.queryBannedUsers({ + payload: { filter_conditions: { user_id: { $eq: banTarget } } }, + }); + expect(bans).toHaveLength(1); + expect(bans[0]).toMatchObject({ reason: 'Repeated spam' }); + expect(bans[0].banned_by?.id).toBe(moderatorId); + expect(bans[0].channel).toBeUndefined(); + // 1440 minutes + expect( + (bans[0].expires?.getTime() ?? 0) - bans[0].created_at.getTime(), + ).toBe(1440 * 60 * 1000); + }); + + it('bans a user from one channel', async () => { + const channelBanTarget = await createUser('channel-ban-target'); + await client.chat + .channel('messaging', channelId) + .update({ add_members: [{ user_id: channelBanTarget }] }); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Channel-Scoped Ban" tab="Node.js" index=1 + // COPY: channelBanTarget="user_to_ban", cid="messaging:general" + await client.moderation.ban({ + target_user_id: channelBanTarget, + banned_by_id: moderatorId, + channel_cid: cid, + reason: 'Trolling in this channel', + timeout: 60, + }); + // #endregion snippet + + // Not banned app-wide, only in the channel. + const user = await getServerUser(client, channelBanTarget); + expect(user.banned).toBe(false); + const { bans } = await client.queryBannedUsers({ + payload: { filter_conditions: { user_id: { $eq: channelBanTarget } } }, + }); + expect(bans).toHaveLength(1); + expect(bans[0]).toMatchObject({ reason: 'Trolling in this channel' }); + expect(bans[0].channel?.cid).toBe(cid); + }); + + it('shadow bans a user', async () => { + const shadowBanTarget = await createUser('user-to-shadow-ban'); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Shadow Ban" tab="Node.js" index=1 + // COPY: shadowBanTarget="user_to_shadow_ban" + await client.moderation.ban({ + target_user_id: shadowBanTarget, + banned_by_id: moderatorId, + reason: 'Disruptive behavior', + shadow: true, + }); + // #endregion snippet + + const user = await getServerUser(client, shadowBanTarget); + expect(user.shadow_banned).toBe(true); + expect(user.banned).toBe(false); + const { bans } = await client.queryBannedUsers({ + payload: { filter_conditions: { user_id: { $eq: shadowBanTarget } } }, + }); + expect(bans).toHaveLength(1); + expect(bans[0]).toMatchObject({ + reason: 'Disruptive behavior', + shadow: true, + }); + }); + + it('unbans a user', async () => { + const unbanTarget = await createUser('user-to-unban'); + await client.moderation.ban({ + target_user_id: unbanTarget, + banned_by_id: moderatorId, + }); + expect((await getServerUser(client, unbanTarget)).banned).toBe(true); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Unban User" tab="Node.js" index=1 + // COPY: unbanTarget="user_to_unban" + await client.moderation.unban({ + target_user_id: unbanTarget, + created_by: moderatorId, + }); + // #endregion snippet + + expect((await getServerUser(client, unbanTarget)).banned).toBe(false); + const { bans } = await client.queryBannedUsers({ + payload: { filter_conditions: { user_id: { $eq: unbanTarget } } }, + }); + expect(bans).toEqual([]); + }); + + it('queries banned users', async () => { + // Bans of `moderatorId`: an app-wide and a channel-scoped one, plus an expired one + // (left out by `exclude_expired_bans`) by the same moderator. + // Its own moderator (shadows the file's one), so the other tests' bans don't match. + const moderatorId = await createUser('ban-query-moderator'); + const bannedUser = await createUser('banned-user'); + const channelBanned = await createUser('channel-banned-user'); + const expiredBanned = await createUser('expired-banned-user'); + const queryChannelId = uniqueId('ban-query-channel'); + const queryCid = `messaging:${queryChannelId}`; + await client.chat.channel('messaging', queryChannelId).getOrCreate({ + data: { + created_by_id: moderatorId, + members: [{ user_id: channelBanned }], + }, + }); + cleanup.channels.push(queryCid); + await client.moderation.ban({ + target_user_id: bannedUser, + banned_by_id: moderatorId, + reason: 'app-wide', + }); + await client.moderation.ban({ + target_user_id: channelBanned, + banned_by_id: moderatorId, + channel_cid: queryCid, + reason: 'channel', + }); + await client.moderation.ban({ + target_user_id: expiredBanned, + banned_by_id: moderatorId, + reason: 'expired', + timeout: 1, + }); + // Wait for the 1-minute ban to expire. + await retry( + async () => { + const user = await getServerUser(client, expiredBanned); + if ((user.ban_expires?.getTime() ?? Infinity) > Date.now()) { + throw new Error('ban not expired yet'); + } + }, + { timeout: 80000, interval: 5000 }, + ); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Query Banned Users" tab="Node.js" index=1 + // COPY: bannedUser="user_to_ban", queryCid="messaging:general" + // All app-wide bans + const response = await client.queryBannedUsers({ + payload: { + filter_conditions: { banned_by_id: { $eq: moderatorId } }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 20, + exclude_expired_bans: true, + }, + }); + + // Bans for a specific user + const userBans = await client.queryBannedUsers({ + payload: { + filter_conditions: { user_id: { $eq: bannedUser } }, + }, + }); + + // Channel-scoped bans + const channelBans = await client.queryBannedUsers({ + payload: { + filter_conditions: { channel_cid: { $eq: queryCid } }, + }, + }); + // #endregion snippet + + // Newest first, expired ban left out. + expect(response.bans.map((b) => b.user?.id)).toEqual([ + channelBanned, + bannedUser, + ]); + expect(response.bans.map((b) => b.banned_by?.id)).toEqual([ + moderatorId, + moderatorId, + ]); + expect(userBans.bans.map((b) => b.reason)).toEqual(['app-wide']); + expect(channelBans.bans.map((b) => b.user?.id)).toEqual([channelBanned]); + expect(channelBans.bans[0].channel?.cid).toBe(queryCid); + }, 150000); + + it('blocks a user', async () => { + const blockingUserId = await createUser('blocking-user'); + const blockedUser = await createUser('user-to-block'); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Block" tab="Node.js" index=1 + // COPY: blockedUser="user_to_block" + await client.blockUsers({ + blocked_user_id: blockedUser, + user_id: blockingUserId, + }); + // #endregion snippet + + const { blocks } = await client.getBlockedUsers({ + user_id: blockingUserId, + }); + expect(blocks.map((b) => b.blocked_user_id)).toEqual([blockedUser]); + }); + + it('enables moderation bypass for a user', async () => { + const trustedUser = await createUser('trusted-user'); + expect( + (await getServerUser(client, trustedUser)).bypass_moderation ?? false, + ).toBe(false); + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="Bypass Moderation" tab="Node.js" index=1 + // COPY: trustedUser="trusted_user_id" + await client.moderation.bypass({ + target_user_id: trustedUser, + enabled: true, + }); + // #endregion snippet + + expect((await getServerUser(client, trustedUser)).bypass_moderation).toBe( + true, + ); + }); + + it.skip('BLOCKED: SDK issue: the node SDK has no getUserModerationReport (GET /api/v2/moderation/user_report is left out of the spec)', async () => { + const reportUser = await createUser('report-user'); + // The node SDK has no getUserModerationReport: type the missing method (from the + // API's response) so the skipped region typechecks and lints. It isn't there at runtime. + const client = getServerClient() as StreamClient & { + moderation: { + getUserModerationReport: (request: { + user_id: string; + }) => Promise<{ user: UserResponse }>; + }; + }; + + // #region snippet docs="moderation/_default/05-content-moderation/02-flag-mute-ban.md" heading="User Moderation Report" tab="Node.js" index=1 + // COPY: reportUser="user_123" + const response = await client.moderation.getUserModerationReport({ + user_id: reportUser, + }); + // #endregion snippet + + expect(response.user.id).toBe(reportUser); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/06-review-queue/01-review-queue.test.ts b/__tests__/docs-snippets/pages/moderation/06-review-queue/01-review-queue.test.ts new file mode 100644 index 0000000..9417fa5 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/06-review-queue/01-review-queue.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'; +import { sendServerMessage, waitForReviewItem } from '../../../helpers/server'; + +const DOCS = 'moderation/_default/06-review-queue/01-review-queue.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const moderatorId = uniqueId('moderator'); + const creatorId = uniqueId('creator'); + const channelId = uniqueId('review-channel'); + const cid = `messaging:${channelId}`; + + /** Flags a fresh message of `creatorId` and returns its review queue item. */ + const flagMessage = async () => { + const message = await sendServerMessage(client, cid, { + text: 'buy cheap followers now', + user_id: creatorId, + }); + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: message.id, + entity_creator_id: creatorId, + reason: 'spam', + user_id: moderatorId, + }); + return waitForReviewItem(client, message.id); + }; + + beforeAll(async () => { + cleanup.users.push(moderatorId, creatorId); + await client.upsertUsers([ + { id: moderatorId, role: 'admin' }, + { id: creatorId }, + ]); + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: moderatorId } }); + cleanup.channels.push(cid); + }); + + afterAll(() => cleanup.run()); + + it('queries the review queue', async () => { + // The filter matches every unreviewed message item of the app: flag a fresh + // message just before, so it is the newest one. A newer item marked as reviewed + // must not show up. + const flagged = await flagMessage(); + const reviewed = await flagMessage(); + await client.moderation.submitAction({ + action_type: 'mark_reviewed', + item_id: reviewed.id, + user_id: moderatorId, + }); + + // #region snippet docs="moderation/_default/06-review-queue/01-review-queue.md" heading="Query Review Queue" tab="Node.js" index=1 + const response = await client.moderation.queryReviewQueue({ + filter: { + entity_type: 'stream:chat:v1:message', + reviewed: false, + }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 20, + }); + // #endregion snippet + + expect(response.items.length).toBeGreaterThan(0); + expect(response.items.length).toBeLessThanOrEqual(20); + expect(response.items[0].id).toBe(flagged.id); + for (const item of response.items) { + expect(item.entity_type).toBe('stream:chat:v1:message'); + expect(item.reviewed_at).toBeFalsy(); + } + expect(response.items.map((item) => item.id)).not.toContain(reviewed.id); + }); + + it('gets a review queue item', async () => { + const flagged = await flagMessage(); + const itemId = flagged.id; + + // #region snippet docs="moderation/_default/06-review-queue/01-review-queue.md" heading="Get Review Queue Item" tab="Node.js" index=1 + // COPY: itemId="item_id" + const response = await client.moderation.getReviewQueueItem({ id: itemId }); + // #endregion snippet + + expect(response.item).toMatchObject({ + id: itemId, + entity_id: flagged.entity_id, + entity_type: 'stream:chat:v1:message', + entity_creator_id: creatorId, + }); + expect(response.item?.flags[0]).toMatchObject({ + reason: 'spam', + user_id: moderatorId, + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/06-review-queue/02-actions.test.ts b/__tests__/docs-snippets/pages/moderation/06-review-queue/02-actions.test.ts new file mode 100644 index 0000000..cfc1de7 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/06-review-queue/02-actions.test.ts @@ -0,0 +1,284 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { StreamClient } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage, waitForReviewItem } from '../../../helpers/server'; + +const DOCS = 'moderation/_default/06-review-queue/02-actions.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const moderatorId = uniqueId('moderator'); + const channelId = uniqueId('actions-channel'); + const cid = `messaging:${channelId}`; + + /** + * Flags a fresh message of a new member (so bans of one test don't affect the + * others) and returns its review queue item, the creator and the message id. + */ + const flagMessage = async () => { + const creatorId = uniqueId('creator'); + cleanup.users.push(creatorId); + await client.upsertUsers([{ id: creatorId }]); + await client.chat + .channel('messaging', channelId) + .update({ add_members: [{ user_id: creatorId }] }); + const message = await sendServerMessage(client, cid, { + text: 'buy cheap followers now', + user_id: creatorId, + }); + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: message.id, + entity_creator_id: creatorId, + reason: 'spam', + user_id: moderatorId, + }); + const item = await waitForReviewItem(client, message.id); + return { itemId: item.id, creatorId, messageId: message.id }; + }; + + const getItem = async (id: string) => + (await client.moderation.getReviewQueueItem({ id })).item; + + const getBans = async (userId: string) => + ( + await client.queryBannedUsers({ + payload: { filter_conditions: { user_id: userId } }, + }) + ).bans; + + /** Creates a new channel as the channel creator (the moderator), with `userId` as member. */ + const createChannelWith = async (userId: string) => { + const channel = client.chat.channel('messaging', uniqueId('new-channel')); + await channel.getOrCreate({ + data: { created_by_id: moderatorId, members: [{ user_id: userId }] }, + }); + cleanup.channels.push(channel.cid); + const { members } = await channel.getOrCreate({ state: true }); + return members.find((member) => member.user_id === userId); + }; + + beforeAll(async () => { + cleanup.users.push(moderatorId); + await client.upsertUsers([{ id: moderatorId, role: 'admin' }]); + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: moderatorId } }); + cleanup.channels.push(cid); + }); + + afterAll(() => cleanup.run()); + + it('marks an item as reviewed', async () => { + const { itemId } = await flagMessage(); + expect((await getItem(itemId))?.reviewed_at).toBeFalsy(); + + // #region snippet docs="moderation/_default/06-review-queue/02-actions.md" heading="Submit Action" tab="Node.js" index=1 + // COPY: itemId="review_queue_item_id" + await client.moderation.submitAction({ + action_type: 'mark_reviewed', + item_id: itemId, + user_id: moderatorId, + }); + // #endregion snippet + + const item = await getItem(itemId); + expect(item?.latest_moderator_action).toBe('mark_reviewed'); + expect(item?.reviewed_at).toBeTruthy(); + expect(item?.reviewed_by).toBe(moderatorId); + }); + + it('deletes a flagged message', async () => { + const { itemId, messageId } = await flagMessage(); + + // #region snippet docs="moderation/_default/06-review-queue/02-actions.md" heading="Delete Message" tab="Node.js" index=1 + // COPY: itemId="review_queue_item_id" + await client.moderation.submitAction({ + action_type: 'delete_message', + item_id: itemId, + user_id: moderatorId, + }); + // #endregion snippet + + const { message } = await client.chat.getMessage({ id: messageId }); + expect(message.deleted_at).toBeTruthy(); + expect((await getItem(itemId))?.latest_moderator_action).toBe( + 'delete_message', + ); + }); + + it('bans the content creator from the channel', async () => { + const { itemId, creatorId } = await flagMessage(); + + // #region snippet docs="moderation/_default/06-review-queue/02-actions.md" heading="Ban User" tab="Node.js" index=1 + // COPY: itemId="review_queue_item_id", cid="messaging:general" + await client.moderation.submitAction({ + action_type: 'ban', + item_id: itemId, + ban: { + reason: 'Repeated harassment', + timeout: 1440, + channel_cid: cid, + channel_ban_only: true, + }, + user_id: moderatorId, + }); + // #endregion snippet + + // A channel ban in the flagged message's channel, not an app-wide one. + const bans = await getBans(creatorId); + expect(bans).toHaveLength(1); + expect(bans[0].channel?.cid).toBe(cid); + expect(bans[0].reason).toBe('Repeated harassment'); + expect(bans[0].expires).toBeDefined(); + expect( + new Date(bans[0].expires ?? 0).getTime() - + new Date(bans[0].created_at).getTime(), + ).toBe(1440 * 60 * 1000); + const { users } = await client.queryUsers({ + payload: { filter_conditions: { id: creatorId } }, + }); + expect(users[0].banned).toBe(false); + }); + + it('bans from future channels and removes that ban', async () => { + const { itemId, creatorId } = await flagMessage(); + + // #region snippet docs="moderation/_default/06-review-queue/02-actions.md" heading="Ban from Future Channels" tab="Node.js" index=1 + // COPY: itemId="review_queue_item_id" + await client.moderation.submitAction({ + action_type: 'ban', + item_id: itemId, + ban: { + reason: 'Repeated harassment', + ban_from_future_channels: true, + }, + user_id: moderatorId, + }); + // #endregion snippet + + // A new channel of the same channel creator bans the user on join. + expect((await createChannelWith(creatorId))?.banned).toBe(true); + expect((await getBans(creatorId)).some((ban) => !ban.channel)).toBe(true); + + // #region snippet docs="moderation/_default/06-review-queue/02-actions.md" heading="Ban from Future Channels" tab="Node.js" index=2 + // COPY: itemId="review_queue_item_id" + await client.moderation.submitAction({ + action_type: 'unban', + item_id: itemId, + unban: { + remove_future_channels_ban: true, + }, + user_id: moderatorId, + }); + // #endregion snippet + + expect((await getItem(itemId))?.latest_moderator_action).toBe('unban'); + expect((await createChannelWith(creatorId))?.banned).toBe(false); + expect((await getBans(creatorId)).some((ban) => !ban.channel)).toBe(false); + }); + + it('restores deleted content', async () => { + const { itemId, messageId } = await flagMessage(); + await client.moderation.submitAction({ + action_type: 'delete_message', + item_id: itemId, + user_id: moderatorId, + }); + expect( + (await client.chat.getMessage({ id: messageId })).message.deleted_at, + ).toBeTruthy(); + + // #region snippet docs="moderation/_default/06-review-queue/02-actions.md" heading="Restore Content" tab="Node.js" index=1 + // COPY: itemId="review_queue_item_id" + await client.moderation.submitAction({ + action_type: 'restore', + item_id: itemId, + restore: { decision_reason: 'Content was incorrectly flagged' }, + user_id: moderatorId, + }); + // #endregion snippet + + const { message } = await client.chat.getMessage({ id: messageId }); + expect(message.deleted_at).toBeUndefined(); + expect((await getItem(itemId))?.latest_moderator_action).toBe('restore'); + }); + + it('triggers a custom action', async () => { + const { itemId } = await flagMessage(); + + // #region snippet docs="moderation/_default/06-review-queue/02-actions.md" heading="Custom Action" tab="Node.js" index=1 + // COPY: itemId="review_queue_item_id" + await client.moderation.submitAction({ + action_type: 'custom', + item_id: itemId, + custom: { + id: 'notify_user', + options: { notification: 'Your content has been reviewed' }, + }, + user_id: moderatorId, + }); + // #endregion snippet + + const item = await getItem(itemId); + expect(item?.latest_moderator_action).toBe('custom:notify_user'); + expect(item?.actions.map((action) => action.type)).toContain( + 'custom:notify_user', + ); + }); + + it.skip("BLOCKED: SDK issue: the node SDK has no bulkSubmitAction (POST /api/v2/moderation/bulk_submit_action is `Ignore: true` in the spec), and the API rejects the `actions` list: code 4 V2BulkSubmitAction failed with error: \"action is a required field, filters 'BulkSubmitActionRequest.filters' Error:Field validation for 'filters' failed on the 'required_without' tag, review_queue_item_ids 'BulkSubmitActionRequest.review_queue_item_ids' Error:Field validation for 'review_queue_item_ids' failed on the 'required_without' tag\"", async () => { + const [first, second, third] = await Promise.all([ + flagMessage(), + flagMessage(), + flagMessage(), + ]); + const itemId1 = first.itemId; + const itemId2 = second.itemId; + const itemId3 = third.itemId; + // The node SDK has no bulkSubmitAction: type the missing method (with the request + // shape the docs use) so the skipped region typechecks and lints. It isn't there + // at runtime. + const client = getServerClient() as StreamClient & { + moderation: { + bulkSubmitAction: (request: { + actions: Array<{ + action_type: string; + item_id: string; + user_id: string; + }>; + }) => Promise; + }; + }; + + // #region snippet docs="moderation/_default/06-review-queue/02-actions.md" heading="Bulk Submit Action" tab="Node.js" index=1 + // COPY: itemId1="item_id_1", itemId2="item_id_2", itemId3="item_id_3" + await client.moderation.bulkSubmitAction({ + actions: [ + { + action_type: 'mark_reviewed', + item_id: itemId1, + user_id: moderatorId, + }, + { + action_type: 'delete_message', + item_id: itemId2, + user_id: moderatorId, + }, + { + action_type: 'mark_reviewed', + item_id: itemId3, + user_id: moderatorId, + }, + ], + }); + // #endregion snippet + + expect((await getItem(itemId1))?.latest_moderator_action).toBe( + 'mark_reviewed', + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/06-review-queue/03-appeals.test.ts b/__tests__/docs-snippets/pages/moderation/06-review-queue/03-appeals.test.ts new file mode 100644 index 0000000..9ca4963 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/06-review-queue/03-appeals.test.ts @@ -0,0 +1,391 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { + GetAppealResponse, + QueryAppealsResponse, + QueryReviewQueueResponse, +} from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage, waitForReviewItem } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; + +const DOCS = 'moderation/_default/06-review-queue/03-appeals.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const moderatorId = uniqueId('moderator'); + const channelId = uniqueId('appeals-channel'); + const cid = `messaging:${channelId}`; + const blockedWord = 'frobnicate'; + const blocklist = uniqueId('blocklist'); + // A channel-level policy, so only this test's channel is moderated by it. + const configKey = `chat:${cid}`; + + /** Creates a channel member with a `uniqueId` id. */ + const createMember = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + await client.chat + .channel('messaging', channelId) + .update({ add_members: [{ user_id: id }] }); + return id; + }; + + /** + * Sends a message of a new member, flags it and deletes it as the moderator (an + * unreviewed flag can't be appealed). Returns the creator, message and item ids. + */ + const deletedMessage = async () => { + const creatorId = await createMember('creator'); + const message = await sendServerMessage(client, cid, { + text: 'buy cheap followers now', + user_id: creatorId, + }); + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: message.id, + entity_creator_id: creatorId, + reason: 'spam', + user_id: moderatorId, + }); + const item = await waitForReviewItem(client, message.id); + await client.moderation.submitAction({ + action_type: 'delete_message', + item_id: item.id, + user_id: moderatorId, + }); + return { creatorId, messageId: message.id, itemId: item.id }; + }; + + /** Appeals a deleted message as its creator and returns the appeal id. */ + const appealDeletedMessage = async () => { + const { creatorId, messageId, itemId } = await deletedMessage(); + const { appeal_id } = await client.moderation.appeal({ + appeal_reason: 'Not spam', + entity_id: messageId, + entity_type: 'stream:chat:v1:message', + user_id: creatorId, + }); + return { appealId: appeal_id, itemId }; + }; + + /** Bans a new member app-wide and returns its id. */ + const bannedUser = async () => { + const id = await createMember('banned'); + await client.moderation.ban({ + target_user_id: id, + banned_by_id: moderatorId, + reason: 'spam', + }); + return id; + }; + + const getAppeal = async (id: string) => + (await client.moderation.getAppeal({ id })).item; + + const getBans = async (userId: string) => + ( + await client.queryBannedUsers({ + payload: { filter_conditions: { user_id: userId } }, + }) + ).bans; + + beforeAll(async () => { + cleanup.users.push(moderatorId); + await client.upsertUsers([{ id: moderatorId, role: 'admin' }]); + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: moderatorId } }); + cleanup.channels.push(cid); + + await client.createBlockList({ name: blocklist, words: [blockedWord] }); + cleanup.add(() => client.deleteBlockList({ name: blocklist })); + // A new blocklist isn't known to the moderation API right away. + await retry( + () => + client.moderation.upsertConfig({ + key: configKey, + block_list_config: { rules: [{ name: blocklist, action: 'remove' }] }, + }), + { retryIf: (error) => String(error).includes('Blocklist not found') }, + ); + cleanup.add(() => client.moderation.deleteConfig({ key: configKey })); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('appeals against deleted content', async () => { + const { creatorId, messageId } = await deletedMessage(); + const contentEntityId = messageId; + const userId = creatorId; + const appeal = vi.spyOn(client.moderation, 'appeal'); + + // #region snippet docs="moderation/_default/06-review-queue/03-appeals.md" heading="Submit Appeal" tab="Node.js" index=1 + await client.moderation.appeal({ + appeal_reason: 'I believe my message was wrongfully blocked', + entity_id: contentEntityId, + entity_type: 'stream:chat:v1:message', + attachments: ['imageurl'], + user_id: userId, + }); + // #endregion snippet + + const { appeal_id } = await (appeal.mock.results[0].value as ReturnType< + typeof client.moderation.appeal + >); + const item = await getAppeal(appeal_id); + expect(item).toMatchObject({ + entity_id: messageId, + entity_type: 'stream:chat:v1:message', + appeal_reason: 'I believe my message was wrongfully blocked', + attachments: ['imageurl'], + status: 'submitted', + channel_cid: cid, + }); + expect(item?.user?.id).toBe(creatorId); + }); + + it('appeals against a user ban', async () => { + const userId = await bannedUser(); + const appeal = vi.spyOn(client.moderation, 'appeal'); + + // #region snippet docs="moderation/_default/06-review-queue/03-appeals.md" heading="Example: Appeal Against User Ban" tab="Node.js" index=1 + await client.moderation.appeal({ + appeal_reason: 'Appeal to unban me', + entity_id: userId, + entity_type: 'stream:user', + attachments: ['imageurl'], + user_id: userId, + }); + // #endregion snippet + + const { appeal_id } = await (appeal.mock.results[0].value as ReturnType< + typeof client.moderation.appeal + >); + const item = await getAppeal(appeal_id); + expect(item).toMatchObject({ + entity_id: userId, + entity_type: 'stream:user', + status: 'submitted', + }); + // The global ban: no channel. + expect(item?.channel_cid ?? '').toBe(''); + }); + + it('appeals against a channel-specific ban', async () => { + const userId = await createMember('channel-banned'); + // Banned app-wide and in the channel, so the appeal must name the channel ban. + await client.moderation.ban({ + target_user_id: userId, + banned_by_id: moderatorId, + reason: 'spam', + }); + await client.moderation.ban({ + target_user_id: userId, + banned_by_id: moderatorId, + reason: 'spam', + channel_cid: cid, + }); + const channelCid = cid; + const appeal = vi.spyOn(client.moderation, 'appeal'); + + // #region snippet docs="moderation/_default/06-review-queue/03-appeals.md" heading="Example: Appeal Against a Channel-Specific Ban" tab="Node.js" index=1 + // COPY: channelCid="messaging:general" + await client.moderation.appeal({ + appeal_reason: 'I was wrongly banned from this channel', + entity_id: userId, + entity_type: 'stream:user', + channel_cid: channelCid, + user_id: userId, + }); + // #endregion snippet + + const { appeal_id } = await (appeal.mock.results[0].value as ReturnType< + typeof client.moderation.appeal + >); + expect(await getAppeal(appeal_id)).toMatchObject({ + entity_id: userId, + entity_type: 'stream:user', + channel_cid: cid, + status: 'submitted', + }); + }); + + it('queries appeals', async () => { + const { appealId } = await appealDeletedMessage(); + const queryAppeals = vi.spyOn(client.moderation, 'queryAppeals'); + + // #region snippet docs="moderation/_default/06-review-queue/03-appeals.md" heading="Query Appeals" tab="Node.js" index=1 + // COPY: appealId="appeal_id" + await client.moderation.queryAppeals({ + filter: { + id: { $eq: appealId }, + }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 20, + }); + // #endregion snippet + + const { items } = await (queryAppeals.mock.results[0] + .value as Promise); + expect(items.map((item) => item.id)).toEqual([appealId]); + }); + + it('gets an appeal', async () => { + const { appealId } = await appealDeletedMessage(); + const getAppealSpy = vi.spyOn(client.moderation, 'getAppeal'); + + // #region snippet docs="moderation/_default/06-review-queue/03-appeals.md" heading="Get Appeal" tab="Node.js" index=1 + // COPY: appealId="appeal_id" + await client.moderation.getAppeal({ id: appealId }); + // #endregion snippet + + const { item } = await (getAppealSpy.mock.results[0] + .value as Promise); + expect(item).toMatchObject({ id: appealId, status: 'submitted' }); + }); + + it('accepts appeals while unbanning and restoring', async () => { + // A ban appeal. + const bannedId = await bannedUser(); + const { appeal_id: appealId } = await client.moderation.appeal({ + appeal_reason: 'Appeal to unban me', + entity_id: bannedId, + entity_type: 'stream:user', + user_id: bannedId, + }); + // A message blocked by the channel's policy, and its creator's appeal. + const blockedCreator = await createMember('blocked'); + const blocked = await retry(async () => { + const message = await sendServerMessage( + client, + cid, + { text: `you ${blockedWord}`, user_id: blockedCreator }, + { force_moderation: true }, + ); + expect(message.type).toBe('error'); + return message; + }); + const itemId = (await waitForReviewItem(client, blocked.id)).id; + const { appeal_id: blockedAppealId } = await client.moderation.appeal({ + appeal_reason: 'Not offensive', + entity_id: blocked.id, + entity_type: 'stream:chat:v1:message', + user_id: blockedCreator, + }); + + // #region snippet docs="moderation/_default/06-review-queue/03-appeals.md" heading="Accepting Appeals" tab="Node.js" index=1 + // COPY: appealId="appeal_id", itemId="item_id" + // Approve appeal while unbanning user + await client.moderation.submitAction({ + action_type: 'unban', + appeal_id: appealId, + unban: { + decision_reason: 'I am unbanning a user', + // channel_cid: "messaging:general", // optional: unban in this channel only + }, + user_id: moderatorId, + }); + + // Approve appeal while restoring content + await client.moderation.submitAction({ + action_type: 'restore', + item_id: itemId, + restore: { decision_reason: 'I am restoring content' }, + user_id: moderatorId, + }); + // #endregion snippet + + expect(await getAppeal(appealId)).toMatchObject({ + status: 'accepted', + decision_reason: 'I am unbanning a user', + }); + expect(await getBans(bannedId)).toEqual([]); + expect(await getAppeal(blockedAppealId)).toMatchObject({ + status: 'accepted', + decision_reason: 'I am restoring content', + }); + const { message } = await client.chat.getMessage({ id: blocked.id }); + expect(message.type).toBe('regular'); + }); + + it('rejects an appeal', async () => { + const bannedId = await bannedUser(); + const { appeal_id: appealId } = await client.moderation.appeal({ + appeal_reason: 'Appeal to unban me', + entity_id: bannedId, + entity_type: 'stream:user', + user_id: bannedId, + }); + + // #region snippet docs="moderation/_default/06-review-queue/03-appeals.md" heading="Rejecting Appeals" tab="Node.js" index=1 + // COPY: appealId="appeal_id" + await client.moderation.submitAction({ + action_type: 'reject_appeal', + appeal_id: appealId, + reject_appeal: { + decision_reason: 'I am rejecting your appeal', + }, + user_id: moderatorId, + }); + // #endregion snippet + + expect(await getAppeal(appealId)).toMatchObject({ + status: 'rejected', + decision_reason: 'I am rejecting your appeal', + }); + // The ban stays. + expect(await getBans(bannedId)).toHaveLength(1); + }); + + it('queries review queue items with appeals', async () => { + const { appealId, itemId } = await appealDeletedMessage(); + // A newer item without an appeal, which the appeal filters must leave out. + const { itemId: withoutAppeal } = await deletedMessage(); + const queryReviewQueue = vi.spyOn(client.moderation, 'queryReviewQueue'); + + // #region snippet docs="moderation/_default/06-review-queue/03-appeals.md" heading="Example: Query Review Queue Items with Appeals" tab="Node.js" index=1 + // COPY: appealId="appeal_id" + // Query items with submitted appeals + await client.moderation.queryReviewQueue({ + filter: { + appeal: true, + appeal_status: 'submitted', + }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 20, + }); + + // Query items with a specific appeal + await client.moderation.queryReviewQueue({ + filter: { + appeal_id: { $eq: appealId }, + }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 20, + }); + // #endregion snippet + + const [submitted, byAppeal] = await Promise.all( + queryReviewQueue.mock.results.map( + (result) => result.value as Promise, + ), + ); + // The newest item with a submitted appeal comes first, not the newer one without. + expect(submitted.items[0].id).toBe(itemId); + expect(submitted.items.map((item) => item.id)).not.toContain(withoutAppeal); + expect(byAppeal.items.map((item) => item.id)).toEqual([itemId]); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/06-review-queue/04-labels.test.ts b/__tests__/docs-snippets/pages/moderation/06-review-queue/04-labels.test.ts new file mode 100644 index 0000000..a907886 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/06-review-queue/04-labels.test.ts @@ -0,0 +1,70 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +const DOCS = 'moderation/_default/06-review-queue/04-labels.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + afterAll(() => cleanup.run()); + + it('classifies content', async () => { + const contentId = uniqueId('msg'); + // A customer-supplied author id; registered in case the API creates the user. + const authorId = uniqueId('author'); + cleanup.users.push(authorId); + + // #region snippet docs="moderation/_default/06-review-queue/04-labels.md" heading="Classify Content" tab="Node.js" index=1 + // COPY: contentId="msg-123", authorId="user-42" + const response = await client.moderation.labels({ + content: 'you are a fucking idiot', + content_type: 'message', + category: 'chat', + content_id: contentId, + user_id: authorId, + }); + // #endregion snippet + + expect(response.content_id).toBe(contentId); + expect(response.labels).toEqual( + expect.arrayContaining(['insult', 'vulgarity']), + ); + expect(response.harm_type).toBe('hateful'); + expect(response.language).toBe('en'); + expect(['keep', 'flag', 'remove']).toContain(response.recommended_action); + // Labels are classification only: no review queue item is created. + const { items } = await client.moderation.queryReviewQueue({ + filter: { entity_id: contentId }, + }); + expect(items).toEqual([]); + }); + + it('queries stored results', async () => { + // #region snippet docs="moderation/_default/06-review-queue/04-labels.md" heading="Query Stored Results" tab="Node.js" index=1 + const response = await client.moderation.queryLabelResults({ + filter: { + category: 'chat', + language: 'en', + harm_type: 'hateful', + date_range: '2026-04-01_2026-04-23', + }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 25, + }); + // #endregion snippet + + // Storage is opt-in and off on the test app: the query returns empty pages. + expect(Array.isArray(response.label_results)).toBe(true); + expect(response.label_results.length).toBeLessThanOrEqual(25); + for (const result of response.label_results) { + expect(result).toMatchObject({ + category: 'chat', + language: 'en', + harm_type: 'hateful', + }); + } + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/06-review-queue/05-custom-views.test.ts b/__tests__/docs-snippets/pages/moderation/06-review-queue/05-custom-views.test.ts new file mode 100644 index 0000000..a54acea --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/06-review-queue/05-custom-views.test.ts @@ -0,0 +1,180 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { StreamError } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +const DOCS = 'moderation/_default/06-review-queue/05-custom-views.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const moderatorId = uniqueId('moderator'); + // Views have server-assigned ids: every view the file creates gets a `uniqueId` + // name, and is deleted by id in cleanup (the leak check doesn't know views). + const viewNames: string[] = []; + const viewName = (prefix: string) => { + const name = uniqueId(prefix); + viewNames.push(name); + return name; + }; + let queueId = ''; + + /** Deletes a view, ignoring one that is already gone. */ + const deleteView = (id: string) => + client.moderation + .deleteQueue({ id, user_id: moderatorId }) + .catch((error: unknown) => { + if (!(error instanceof StreamError && error.code === 16)) throw error; + }); + + beforeAll(async () => { + cleanup.users.push(moderatorId); + await client.upsertUsers([{ id: moderatorId, role: 'admin' }]); + // Local leak check for views (capped at 10 per app). Added first, so it runs + // after the view deletions and before the moderator is deleted (`listQueues` + // with a missing `user_id` would create that user again). + cleanup.add(async () => { + const { queues } = await client.moderation.listQueues({ + user_id: moderatorId, + }); + const leaked = queues.filter((queue) => viewNames.includes(queue.name)); + await Promise.all(leaked.map((queue) => deleteView(queue.id))); + if (leaked.length) { + throw new Error( + `LEAK: views ${leaked.map((queue) => queue.name).join(', ')}`, + ); + } + }); + + // The view the list / get / update / delete snippets act on. + const { queue } = await client.moderation.createQueue({ + name: viewName('shared-view'), + type: 'personal_view', + description: 'Created by the docs snippet tests', + user_id: moderatorId, + filters: { entity_type: 'stream:chat:v1:message', reporter_type: 'user' }, + }); + queueId = queue!.id; + cleanup.add(() => deleteView(queueId)); + }); + + afterAll(() => cleanup.run()); + + it('creates a view', async () => { + const flaggedImagesName = viewName('flagged-images'); + + // #region snippet docs="moderation/_default/06-review-queue/05-custom-views.md" heading="Create a View" tab="Node.js" index=1 + // COPY: flaggedImagesName="Flagged images", moderatorId="moderator-1" + const response = await client.moderation.createQueue({ + name: flaggedImagesName, + type: 'personal_view', + description: 'Unreviewed image content flagged by the AI image engine', + user_id: moderatorId, + filters: { + entity_type: 'stream:chat:v1:message', + has_image: true, + category: 'ai_image', + }, + sort: [{ field: 'created_at', direction: -1 }], + }); + // #endregion snippet + + const id = response.queue!.id; + cleanup.add(() => deleteView(id)); + expect(response.queue).toMatchObject({ + name: flaggedImagesName, + type: 'personal_view', + description: 'Unreviewed image content flagged by the AI image engine', + created_by: moderatorId, + filters: { + entity_type: 'stream:chat:v1:message', + has_image: true, + category: 'ai_image', + }, + sort: [{ field: 'created_at', direction: -1 }], + }); + expect(typeof id).toBe('string'); + }); + + it('lists views', async () => { + // #region snippet docs="moderation/_default/06-review-queue/05-custom-views.md" heading="List Views" tab="Node.js" index=1 + // COPY: moderatorId="moderator-1" + const response = await client.moderation.listQueues({ + user_id: moderatorId, + }); + // #endregion snippet + + const view = response.queues.find((queue) => queue.id === queueId); + expect(view?.created_by).toBe(moderatorId); + expect(typeof view?.item_count).toBe('number'); + }); + + it('gets a view', async () => { + // #region snippet docs="moderation/_default/06-review-queue/05-custom-views.md" heading="Get a View" tab="Node.js" index=1 + // COPY: queueId="queue-id", moderatorId="moderator-1" + const response = await client.moderation.getQueue({ + id: queueId, + user_id: moderatorId, + }); + // #endregion snippet + + expect(response.queue).toMatchObject({ + id: queueId, + type: 'personal_view', + created_by: moderatorId, + }); + }); + + it('updates a view', async () => { + const flaggedImagesEuName = viewName('flagged-images-eu'); + + // #region snippet docs="moderation/_default/06-review-queue/05-custom-views.md" heading="Update a View" tab="Node.js" index=1 + // COPY: queueId="queue-id", flaggedImagesEuName="Flagged images (EU)", moderatorId="moderator-1" + const response = await client.moderation.updateQueue({ + id: queueId, + name: flaggedImagesEuName, + user_id: moderatorId, + filters: { + entity_type: 'stream:chat:v1:message', + has_image: true, + category: 'ai_image', + label: 'nudity', + }, + }); + // #endregion snippet + + // Omitted fields are kept; `filters` is replaced (the old `reporter_type` is gone). + expect(response.queue).toMatchObject({ + id: queueId, + name: flaggedImagesEuName, + description: 'Created by the docs snippet tests', + type: 'personal_view', + }); + expect(response.queue?.filters).toEqual({ + entity_type: 'stream:chat:v1:message', + has_image: true, + category: 'ai_image', + label: 'nudity', + }); + }); + + it('deletes a view', async () => { + // #region snippet docs="moderation/_default/06-review-queue/05-custom-views.md" heading="Delete a View" tab="Node.js" index=1 + // COPY: queueId="queue-id", moderatorId="moderator-1" + const response = await client.moderation.deleteQueue({ + id: queueId, + user_id: moderatorId, + }); + // #endregion snippet + + expect(response.metadata.responseCode).toBe(201); + await expect( + client.moderation.getQueue({ id: queueId, user_id: moderatorId }), + ).rejects.toMatchObject({ code: 16, metadata: { responseCode: 404 } }); + const { queues } = await client.moderation.listQueues({ + user_id: moderatorId, + }); + expect(queues.map((queue) => queue.id)).not.toContain(queueId); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/09-logs-and-analytics/01-logs-and-analytics.test.ts b/__tests__/docs-snippets/pages/moderation/09-logs-and-analytics/01-logs-and-analytics.test.ts new file mode 100644 index 0000000..b43a97e --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/09-logs-and-analytics/01-logs-and-analytics.test.ts @@ -0,0 +1,188 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { StreamClient, UserResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage, waitForReviewItem } from '../../../helpers/server'; + +const DOCS = + 'moderation/_default/09-logs-and-analytics/01-logs-and-analytics.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + const moderatorId = uniqueId('moderator'); + const channelId = uniqueId('logs-channel'); + const cid = `messaging:${channelId}`; + + /** Creates a channel member with a `uniqueId` id. */ + const createMember = async (name: string) => { + const id = uniqueId(name); + cleanup.users.push(id); + await client.upsertUsers([{ id }]); + await client.chat + .channel('messaging', channelId) + .update({ add_members: [{ user_id: id }] }); + return id; + }; + + /** Sends a message of `creatorId`, flags it and returns its review queue item. */ + const flagMessage = async (creatorId: string) => { + const message = await sendServerMessage(client, cid, { + text: 'buy cheap followers now', + user_id: creatorId, + }); + await client.moderation.flag({ + entity_type: 'stream:chat:v1:message', + entity_id: message.id, + entity_creator_id: creatorId, + reason: 'spam', + user_id: moderatorId, + }); + return waitForReviewItem(client, message.id); + }; + + beforeAll(async () => { + cleanup.users.push(moderatorId); + await client.upsertUsers([{ id: moderatorId, role: 'admin' }]); + await client.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: moderatorId } }); + cleanup.channels.push(cid); + }); + + afterAll(() => cleanup.run()); + + it('queries moderation logs', async () => { + const creatorId = await createMember('creator'); + const item = await flagMessage(creatorId); + await client.moderation.submitAction({ + action_type: 'delete_message', + item_id: item.id, + user_id: moderatorId, + }); + + // #region snippet docs="moderation/_default/09-logs-and-analytics/01-logs-and-analytics.md" heading="Query Moderation Logs" tab="Node.js" index=1 + const response = await client.moderation.queryModerationLogs({ + filter: { type: 'delete_message' }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 50, + }); + // #endregion snippet + + // The newest delete_message log is the test's action. + expect(response.logs[0]).toMatchObject({ + type: 'delete_message', + user_id: moderatorId, + target_user_id: creatorId, + }); + expect(response.logs[0].review_queue_item?.id).toBe(item.id); + expect(response.logs.every((log) => log.type === 'delete_message')).toBe( + true, + ); + }); + + it.skip('BLOCKED: SDK issue: the node SDK has no exportModerationLogs (POST /api/v2/moderation/logs/export is `Ignore: true` in the spec)', async () => { + // The node SDK has no exportModerationLogs: type the missing method (with the + // request shape the docs use) so the skipped region typechecks and lints. It isn't + // there at runtime. + const client = getServerClient() as StreamClient & { + moderation: { + exportModerationLogs: (request: { + filter: Record; + }) => Promise<{ task_id: string }>; + }; + }; + + // #region snippet docs="moderation/_default/09-logs-and-analytics/01-logs-and-analytics.md" heading="Export Moderation Logs" tab="Node.js" index=1 + const response = await client.moderation.exportModerationLogs({ + filter: { created_at: { $gte: '2024-01-01T00:00:00Z' } }, + }); + // #endregion snippet + + expect(response.task_id).toBeTruthy(); + }); + + it.skip('BLOCKED: SDK issue: the node SDK has no getModerationAnalytics (POST /api/v2/moderation/analytics is `Ignore: true` in the spec)', async () => { + // The node SDK has no getModerationAnalytics: type the missing method (with the + // request shape the docs use) so the skipped region typechecks and lints. It isn't + // there at runtime. + const client = getServerClient() as StreamClient & { + moderation: { + getModerationAnalytics: (request: { + from: string; + to: string; + }) => Promise<{ analytics: Record }>; + }; + }; + + // #region snippet docs="moderation/_default/09-logs-and-analytics/01-logs-and-analytics.md" heading="Get Moderation Analytics" tab="Node.js" index=1 + const response = await client.moderation.getModerationAnalytics({ + from: '2024-01-01T00:00:00Z', + to: '2024-01-31T23:59:59Z', + }); + // #endregion snippet + + expect(response.analytics).toBeDefined(); + }); + + it.skip('BLOCKED: SDK issue: the node SDK has no queryUsageStats (POST /api/v2/moderation/usage_stats is `Ignore: true` in the spec)', async () => { + // The node SDK has no queryUsageStats: type the missing method (with the request + // shape the docs use) so the skipped region typechecks and lints. It isn't there + // at runtime. + const client = getServerClient() as StreamClient & { + moderation: { + queryUsageStats: (request: { + from: string; + to: string; + }) => Promise<{ items: unknown[] }>; + }; + }; + + // #region snippet docs="moderation/_default/09-logs-and-analytics/01-logs-and-analytics.md" heading="Query Usage Stats" tab="Node.js" index=1 + const response = await client.moderation.queryUsageStats({ + from: '2024-01-01T00:00:00Z', + to: '2024-01-31T23:59:59Z', + }); + // #endregion snippet + + expect(Array.isArray(response.items)).toBe(true); + }); + + it.skip('BLOCKED: SDK issue: the node SDK has no getUserModerationReport (GET /api/v2/moderation/user_report is left out of the spec)', async () => { + const reportUser = await createMember('report-user'); + // The node SDK has no getUserModerationReport: type the missing method (from the + // API's response) so the skipped region typechecks and lints. It isn't there at runtime. + const client = getServerClient() as StreamClient & { + moderation: { + getUserModerationReport: (request: { + user_id: string; + }) => Promise<{ user: UserResponse }>; + }; + }; + + // #region snippet docs="moderation/_default/09-logs-and-analytics/01-logs-and-analytics.md" heading="Get User Moderation Report" tab="Node.js" index=1 + // COPY: reportUser="user_123" + const response = await client.moderation.getUserModerationReport({ + user_id: reportUser, + }); + // #endregion snippet + + expect(response.user.id).toBe(reportUser); + }); + + it('gets the flag count of a user', async () => { + const flaggedUser = await createMember('flagged'); + await flagMessage(flaggedUser); + await flagMessage(flaggedUser); + + // #region snippet docs="moderation/_default/09-logs-and-analytics/01-logs-and-analytics.md" heading="Get Flag Count" tab="Node.js" index=1 + // COPY: flaggedUser="user_123" + const response = await client.moderation.getFlagCount({ + entity_creator_id: flaggedUser, + }); + // #endregion snippet + + expect(response.count).toBe(2); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/10-multitenancy/01-multitenancy.test.ts b/__tests__/docs-snippets/pages/moderation/10-multitenancy/01-multitenancy.test.ts new file mode 100644 index 0000000..e6e0951 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/10-multitenancy/01-multitenancy.test.ts @@ -0,0 +1,397 @@ +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 { + waitForAppSetting, + waitForTeamIdSupport, +} from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; + +const DOCS = 'moderation/_default/10-multitenancy/01-multitenancy.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + /** The docs' "team-a" / "team-b". Teams are plain strings, not resources. */ + const teamA = uniqueId('team-a'); + const teamB = uniqueId('team-b'); + /** Used only to wait for multi-tenancy to settle (`waitForTeamIdSupport`). */ + const probeTeam = uniqueId('probe'); + + /** The docs' "chat:messaging" team policy, as a test-owned channel-level key. */ + const configKey = `chat:messaging:${uniqueId('channel')}`; + /** An older team-a policy, so the team query's sort can be checked. */ + const olderConfigKey = `chat:messaging:${uniqueId('older-channel')}`; + /** A policy without a team, used to put a team-a user in the review queue. */ + const reviewConfigKey = `chat:messaging:${uniqueId('review-channel')}`; + + /** The docs' "myblocklist", "my_team_blocklist" and "my_global_blocklist". */ + const myBlocklist = uniqueId('myblocklist'); + const teamBlocklist = uniqueId('team-blocklist'); + const globalBlocklist = uniqueId('global-blocklist'); + + const author = uniqueId('author'); + + const isNotFound = (error: unknown) => + /not found|doesn't exist|does not exist/i.test(String(error)); + const ignoreNotFound = (error: unknown) => { + if (!isNotFound(error)) throw error; + }; + /** `upsertConfig` retried while a new blocklist isn't known everywhere yet. */ + const isBlocklistMissing = (error: unknown) => + String(error).includes('Blocklist not found'); + + 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 policies 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()); + + describe('with multi-tenancy', () => { + beforeAll(async () => { + // Team policies fail with code 4 "Team moderation configuration is not + // supported in single tenant mode" while multi-tenancy is off. + await client.updateApp({ multi_tenant_enabled: true }); + await waitForAppSetting(client, 'multi_tenant_enabled', true); + await waitForTeamIdSupport(client, probeTeam, true); + + // Team blocklists aren't listed by the leak check (`listBlockLists()` without a team). + for (const name of [myBlocklist, teamBlocklist]) { + cleanup.add(() => + client.deleteBlockList({ name, team: teamA }).catch(ignoreNotFound), + ); + } + cleanup.add(() => + client.deleteBlockList({ name: globalBlocklist }).catch(ignoreNotFound), + ); + // Team policies need their team to be deleted. + for (const key of [configKey, olderConfigKey]) { + cleanup.add(() => + client.moderation + .deleteConfig({ key, team: teamA }) + .catch(ignoreNotFound), + ); + } + cleanup.add(() => + client.moderation + .deleteConfig({ key: reviewConfigKey }) + .catch(ignoreNotFound), + ); + + cleanup.users.push(author); + await client.upsertUsers([{ id: author, teams: [teamA] }]); + await client.createBlockList({ + name: teamBlocklist, + team: teamA, + words: ['frobnicate'], + }); + await client.createBlockList({ + name: globalBlocklist, + words: ['quuxify'], + }); + + await client.moderation.upsertConfig({ + key: olderConfigKey, + team: teamA, + ai_text_config: { rules: [{ label: 'SPAM', action: 'flag' }] }, + }); + await retry( + () => + client.moderation.upsertConfig({ + key: reviewConfigKey, + block_list_config: { + rules: [{ name: globalBlocklist, action: 'flag' }], + }, + }), + { retryIf: isBlocklistMissing }, + ); + }); + + it('creates a team policy', async () => { + // #region snippet docs="moderation/_default/10-multitenancy/01-multitenancy.md" heading="Server Side Integration" tab="Node.js" index=1 + // COPY: configKey="chat:messaging", teamA="team-a" + await client.moderation.upsertConfig({ + key: configKey, + team: teamA, + ai_text_config: { + rules: [{ label: 'SEXUAL_HARASSMENT', action: 'flag' }], + }, + ai_image_config: { + rules: [ + { + label: 'Non-Explicit Nudity', + action: 'flag', + min_confidence: 50, + }, + ], + }, + }); + // #endregion snippet + + const { config } = await client.moderation.getConfig({ + key: configKey, + team: teamA, + }); + expect(config?.team).toBe(teamA); + expect( + config?.ai_text_config?.rules?.map(({ label, action }) => ({ + label, + action, + })), + ).toEqual([{ label: 'SEXUAL_HARASSMENT', action: 'flag' }]); + expect( + config?.ai_image_config?.rules?.map(({ label, action }) => ({ + label, + action, + })), + ).toEqual([{ label: 'Non-Explicit Nudity', action: 'flag' }]); + // A team policy is only found with its team. + const error: unknown = await client.moderation + .getConfig({ key: configKey }) + .then(() => undefined) + .catch((e: unknown) => e); + expect(isNotFound(error)).toBe(true); + }); + + it('queries team policies', async () => { + // #region snippet docs="moderation/_default/10-multitenancy/01-multitenancy.md" heading="Using Server side integration" tab="Node.js" index=1 + // COPY: teamA="team-a" + // Get all the moderation configs for all the teams + const { configs, next } = await client.moderation.queryModerationConfigs({ + limit: 2, + }); + + // Get all the moderation configs for the team `team-a` + const { configs: teamConfigs, next: teamNext } = + await client.moderation.queryModerationConfigs({ + filter: { team: teamA }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 2, + }); + // #endregion snippet + + expect(configs.length).toBeLessThanOrEqual(2); + // The API returns no `next` cursor here, although the app has more configs. + expect(next ?? '').toBeTypeOf('string'); + // Newest first: the "creates a team policy" one, then the older one from setup. + expect(teamConfigs.map((c) => c.key)).toEqual([ + configKey, + olderConfigKey, + ]); + expect(teamConfigs.every((c) => c.team === teamA)).toBe(true); + expect(teamNext).toBeUndefined(); + }); + + it('queries the team review queue', async () => { + // Flagged content by a team-a user puts that user in the review queue, + // with the user's teams. + const flagged = await retry(async () => { + const response = await client.moderation.check({ + entity_type: 'stream:chat:v1:message', + entity_id: uniqueId('message'), + entity_creator_id: author, + config_key: reviewConfigKey, + moderation_payload: { texts: ['please quuxify this'] }, + }); + if (response.recommended_action !== 'flag') { + throw new Error(`not flagged yet: ${response.recommended_action}`); + } + return response; + }); + expect(flagged.recommended_action).toBe('flag'); + const item = await retry(async () => { + const { items } = await client.moderation.queryReviewQueue({ + filter: { entity_type: 'stream:user', entity_id: author }, + }); + if (items.length !== 1) throw new Error('not in the review queue yet'); + return items[0]; + }); + expect(item.teams).toEqual([teamA]); + + // #region snippet docs="moderation/_default/10-multitenancy/01-multitenancy.md" heading="Server Side Integration" tab="Node.js" index=2 + // COPY: teamA="team-a", teamB="team-b" + // Get all the review queue for all the teams + const { items, next } = await client.moderation.queryReviewQueue({ + limit: 2, + }); + + // Get all the review queue for the team `team-a` + const { items: teamItems, next: teamNext } = + await client.moderation.queryReviewQueue({ + filter: { teams: teamA }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 2, + }); + + // Get all the review queue for the team `team-a` and `team-b` + const { items: multiTeamItems, next: multiTeamNext } = + await client.moderation.queryReviewQueue({ + filter: { teams: { $in: [teamA, teamB] } }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 2, + }); + // #endregion snippet + + expect(items.length).toBeLessThanOrEqual(2); + expect(next ?? '').toBeTypeOf('string'); + expect(teamItems.map((i) => i.id)).toEqual([item.id]); + expect(teamNext).toBeFalsy(); + expect(multiTeamItems.map((i) => i.id)).toEqual([item.id]); + expect(multiTeamNext).toBeFalsy(); + }); + + it('creates a team blocklist', async () => { + // #region snippet docs="moderation/_default/10-multitenancy/01-multitenancy.md" heading="Create a Team Level Blocklist" tab="Node.js" index=1 + // COPY: myBlocklist="myblocklist", teamA="team-a" + await client.createBlockList({ + name: myBlocklist, + team: teamA, + words: ['bad-word-1', 'bad-word-2'], + type: 'word', + }); + // #endregion snippet + + const { blocklist } = await client.getBlockList({ + name: myBlocklist, + team: teamA, + }); + expect(blocklist?.team).toBe(teamA); + expect(blocklist?.type).toBe('word'); + expect(blocklist?.words).toEqual(['bad-word-1', 'bad-word-2']); + }); + + it('updates a team blocklist', async () => { + // #region snippet docs="moderation/_default/10-multitenancy/01-multitenancy.md" heading="Update a Team Level Blocklist" tab="Node.js" index=1 + // COPY: myBlocklist="myblocklist", teamA="team-a" + await client.updateBlockList({ + name: myBlocklist, + words: ['test3', 'test4'], + team: teamA, + }); + // #endregion snippet + + const { blocklist } = await client.getBlockList({ + name: myBlocklist, + team: teamA, + }); + expect(blocklist?.words).toEqual(['test3', 'test4']); + }); + + it('deletes a team blocklist', async () => { + // #region snippet docs="moderation/_default/10-multitenancy/01-multitenancy.md" heading="Delete a Team Level Blocklist" tab="Node.js" index=1 + // COPY: myBlocklist="myblocklist", teamA="team-a" + await client.deleteBlockList({ + name: myBlocklist, + team: teamA, + }); + // #endregion snippet + + await retry(async () => { + const error: unknown = await client + .getBlockList({ name: myBlocklist, team: teamA }) + .then(() => undefined) + .catch((e: unknown) => e); + if (!isNotFound(error)) throw new Error('blocklist still there'); + }); + }); + + it.skip('BLOCKED: ListBlockLists with team fails with code 4 "Multi-tenant blocklist is not enabled for this app"', async () => { + // #region snippet docs="moderation/_default/10-multitenancy/01-multitenancy.md" heading="List Blocklists" tab="Node.js" index=1 + // COPY: teamA="team-a" + // List global blocklists + const globalBlocklists = await client.listBlockLists(); + + // List team specific blocklists + const teamBlocklists = await client.listBlockLists({ team: teamA }); + // #endregion snippet + + expect(globalBlocklists.blocklists.map((b) => b.name)).not.toContain( + teamBlocklist, + ); + expect(teamBlocklists.blocklists.map((b) => b.name)).toContain( + teamBlocklist, + ); + }); + + it('uses team and global blocklists in a team policy', async () => { + const upsertConfig = client.moderation.upsertConfig.bind( + client.moderation, + ); + vi.spyOn(client.moderation, 'upsertConfig').mockImplementation( + (request) => + retry(() => upsertConfig(request), { retryIf: isBlocklistMissing }), + ); + + // #region snippet docs="moderation/_default/10-multitenancy/01-multitenancy.md" heading="Using Blocklists in Team Level Moderation Configuration" tab="Node.js" index=1 + // COPY: configKey="chat:messaging", teamA="team-a", teamBlocklist="my_team_blocklist", globalBlocklist="my_global_blocklist" + await client.moderation.upsertConfig({ + key: configKey, + team: teamA, + block_list_config: { + rules: [ + // team level blocklist + { + name: teamBlocklist, + team: teamA, + action: 'remove', + }, + // global blocklist + { + name: globalBlocklist, + action: 'flag', + }, + ], + }, + }); + // #endregion snippet + + const { config } = await client.moderation.getConfig({ + key: configKey, + team: teamA, + }); + expect( + config?.block_list_config?.rules?.map(({ name, team, action }) => ({ + name, + team, + action, + })), + ).toEqual([ + { name: teamBlocklist, team: teamA, action: 'remove' }, + { name: globalBlocklist, team: '', action: 'flag' }, + ]); + + // API behavior (2026-10-16): a team policy is not applied by `check` with + // `config_team` (nor to messages in team channels): a word of its global + // blocklist is kept, while the same rule in a policy without a team flags it + // (the review queue test above). + const { recommended_action } = await client.moderation.check({ + entity_type: 'stream:chat:v1:message', + entity_id: uniqueId('message'), + entity_creator_id: author, + config_key: configKey, + config_team: teamA, + moderation_payload: { texts: ['please quuxify'] }, + }); + expect(recommended_action).toBe('keep'); + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/11-guides/07-client-configuration.test.ts b/__tests__/docs-snippets/pages/moderation/11-guides/07-client-configuration.test.ts new file mode 100644 index 0000000..c33f908 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/11-guides/07-client-configuration.test.ts @@ -0,0 +1,38 @@ +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="moderation/_default/11-guides/07-client-configuration.md" heading="Node" tab="Node.js" index=1 +// COPY: apiKey="YOUR_API_KEY", apiSecret="YOUR_API_SECRET" +import { Agent } from 'undici'; + +const client = new StreamClient(apiKey, apiSecret, { + timeout: 10000, // Request timeout in milliseconds + agent: new Agent({ + keepAliveTimeout: 30000, // Keep-alive timeout in ms + keepAliveMaxTimeout: 60000, // Max keep-alive timeout in ms + connections: 50, // Max concurrent connections per host + pipelining: 1, // HTTP pipelining + }), +}); +// #endregion snippet + +describe('moderation/_default/11-guides/07-client-configuration.md', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls the API with the custom timeout and undici Agent', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout'); + const dispatch = vi.spyOn(Agent.prototype, 'dispatch'); + + const response = await client.getApp(); + + expect(typeof response.app.name).toBe('string'); + expect(timeout).toHaveBeenCalledWith(10000); + expect(dispatch).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/11-guides/09-rule-triggered-webhooks.test.ts b/__tests__/docs-snippets/pages/moderation/11-guides/09-rule-triggered-webhooks.test.ts new file mode 100644 index 0000000..3f23362 --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/11-guides/09-rule-triggered-webhooks.test.ts @@ -0,0 +1,68 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import type { UpsertModerationRuleResponse } from '../../../../../index'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +const DOCS = 'moderation/_default/11-guides/09-rule-triggered-webhooks.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('creates a webhook_only content rule', async () => { + // The docs' "chat:messaging" / "chat:livestream" config keys, test-owned: on the app's + // own configs the rule would run on every chat message of the shared app. + const configKey = `chat:messaging:${uniqueId('rule-hooks')}`; + const livestreamConfigKey = `chat:livestream:${uniqueId('rule-hooks')}`; + // The docs' rule name, test-owned (rules are app-wide). + const ruleName = uniqueId('high-profile-harms'); + const upsertRule = vi.spyOn(client.moderation, 'upsertModerationRule'); + + // #region snippet docs="moderation/_default/11-guides/09-rule-triggered-webhooks.md" heading="Content rule: notify on a harm label" tab="Node.js" index=1 + // COPY: configKey="chat:messaging", livestreamConfigKey="chat:livestream", ruleName="High-profile harms" + await client.moderation.upsertModerationRule({ + name: ruleName, + description: 'Notify when selected harm labels are detected', + config_keys: [configKey, livestreamConfigKey], + enabled: true, + rule_type: 'content', + conditions: [ + { + type: 'text_content', + text_content_params: { + harm_labels: ['UNDERAGE_USER', 'SCAM'], + }, + }, + ], + action: { type: 'webhook_only' }, + }); + // #endregion snippet + + const { rule } = await (upsertRule.mock.results[0] + .value as Promise); + if (!rule) throw new Error('upsertModerationRule returned no rule'); + cleanup.add(() => + client.moderation + .deleteModerationRule({ id: rule.id }) + .then(() => undefined), + ); + expect(rule).toMatchObject({ + name: ruleName, + description: 'Notify when selected harm labels are detected', + config_keys: [configKey, livestreamConfigKey], + enabled: true, + rule_type: 'content', + action: { type: 'webhook_only' }, + }); + expect(rule.conditions).toEqual([ + { + type: 'text_content', + text_content_params: { harm_labels: ['UNDERAGE_USER', 'SCAM'] }, + }, + ]); + }); +}); diff --git a/__tests__/docs-snippets/pages/moderation/node/01-getting-started/01-getting-started.test.ts b/__tests__/docs-snippets/pages/moderation/node/01-getting-started/01-getting-started.test.ts new file mode 100644 index 0000000..5b442fe --- /dev/null +++ b/__tests__/docs-snippets/pages/moderation/node/01-getting-started/01-getting-started.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 { retry } from '../../../../helpers/wait'; + +// 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="moderation/node/01-getting-started/01-getting-started.md" heading="Initialize the 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 + +const DOCS = 'moderation/node/01-getting-started/01-getting-started.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + // The docs' "my_config" policy and "profanity_en" blocklist, both test-owned. + const myConfig = uniqueId('my-config'); + const profanityBlocklist = uniqueId('profanity'); + const blockedWord = 'frobnicate'; + const userId = uniqueId('user'); + + beforeAll(async () => { + cleanup.users.push(userId); + await serverClient.upsertUsers([{ id: userId }]); + await serverClient.createBlockList({ + name: profanityBlocklist, + words: [blockedWord], + }); + cleanup.add(() => + serverClient.deleteBlockList({ name: profanityBlocklist }), + ); + cleanup.add(() => + serverClient.moderation + .deleteConfig({ key: myConfig }) + .catch((error: unknown) => { + if (!String(error).includes('not found')) throw error; + }), + ); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('initializes the client', async () => { + expect(client).toBeInstanceOf(StreamClient); + expect(client.apiKey).toBe(apiKey); + expect(client.moderation).toBeDefined(); + const { app } = await client.getApp(); + expect(typeof app.name).toBe('string'); + }); + + it('creates a moderation policy', async () => { + // A new blocklist isn't known to the moderation API right away ("Blocklist not found"). + const upsertConfig = client.moderation.upsertConfig.bind(client.moderation); + vi.spyOn(client.moderation, 'upsertConfig').mockImplementation((request) => + retry(() => upsertConfig(request), { + retryIf: (error) => String(error).includes('Blocklist not found'), + }), + ); + + // #region snippet docs="moderation/node/01-getting-started/01-getting-started.md" heading="Create a moderation policy" tab="Node.js" index=1 + // COPY: myConfig="my_config", profanityBlocklist="profanity_en" + await client.moderation.upsertConfig({ + key: myConfig, + ai_text_config: { + rules: [ + { label: 'SPAM', action: 'flag' }, + { label: 'HATRED', action: 'remove' }, + ], + }, + block_list_config: { + rules: [{ name: profanityBlocklist, action: 'remove' }], + }, + }); + // #endregion snippet + + const { config } = await serverClient.moderation.getConfig({ + key: myConfig, + }); + expect(config?.key).toBe(myConfig); + expect( + config?.ai_text_config?.rules?.map(({ label, action }) => ({ + label, + action, + })), + ).toEqual([ + { label: 'SPAM', action: 'flag' }, + { label: 'HATRED', action: 'remove' }, + ]); + expect( + config?.block_list_config?.rules?.map(({ name, action }) => ({ + name, + action, + })), + ).toEqual([{ name: profanityBlocklist, action: 'remove' }]); + }); + + it('checks content', async () => { + const messageId = uniqueId('message'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="moderation/node/01-getting-started/01-getting-started.md" heading="Check content" tab="Node.js" index=1 + // COPY: messageId="message-123", userId="user-456", myConfig="my_config" + const response = await client.moderation.check({ + entity_type: 'stream:chat:v1:message', + entity_id: messageId, + entity_creator_id: userId, + moderation_payload: { + texts: ['Hello, this is a test message'], + }, + config_key: myConfig, + }); + + console.log(response.recommended_action); // "keep", "flag", or "remove" + // #endregion snippet + + expect(response.status).toBe('complete'); + expect(response.recommended_action).toBe('keep'); + expect(log.mock.calls).toEqual([['keep']]); + }); + + it('handles the response', async () => { + // A text with a word from the policy's blocklist, so the "remove" branch runs. + // The new blocklist and policy take a few seconds to be used by every API node. + const response = await retry(async () => { + const result = await client.moderation.check({ + entity_type: 'stream:chat:v1:message', + entity_id: uniqueId('message'), + entity_creator_id: userId, + moderation_payload: { texts: [`you ${blockedWord}`] }, + config_key: myConfig, + }); + if (result.recommended_action !== 'remove') { + throw new Error(`recommended_action: ${result.recommended_action}`); + } + return result; + }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="moderation/node/01-getting-started/01-getting-started.md" heading="Handle the response" tab="Node.js" index=1 + if (response.recommended_action === 'keep') { + // Content is safe, no action needed + } else if (response.recommended_action === 'flag') { + // Content is suspicious; Stream has queued it for review + console.log('Flagged for review:', response.item); + } else if (response.recommended_action === 'remove') { + // Content violates policies, remove it + console.log('Content removed:', response.item); + } + // #endregion snippet + + expect(response.recommended_action).toBe('remove'); + expect(log.mock.calls).toHaveLength(1); + expect(log.mock.calls[0][0]).toBe('Content removed:'); + expect(log.mock.calls[0][1]).toBe(response.item); + expect(typeof response.item?.id).toBe('string'); + }); +}); 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 c062bbd..1f3624f 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 @@ -1,5 +1,4 @@ import crypto from 'crypto'; -import { isDeepStrictEqual } from 'util'; import zlib from 'zlib'; import { afterAll, @@ -18,19 +17,13 @@ import type { import { InvalidWebhookError } from '../../../../../index'; import { getServerClient } from '../../../helpers/clients'; import { Cleanup } from '../../../helpers/cleanup'; -import { retry } from '../../../helpers/wait'; +import { + saveAndRestoreEventHooks, + waitForEventHooks, +} from '../../../helpers/server'; 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', @@ -47,31 +40,9 @@ describe(DOCS, () => { const client = getServerClient(); const cleanup = new Cleanup(client); let originalHooks: EventHook[] = []; - - /** Reads `event_hooks` until `check` accepts them (getApp is eventually consistent). */ + let restoreHooks: () => Promise; 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), - ), - ); - } - }; + waitForEventHooks(client, check); /** The hooks that are there before a "preserve existing hooks" snippet runs. */ const expectOriginalHooksKept = (hooks: EventHook[]) => { @@ -82,8 +53,8 @@ describe(DOCS, () => { }; beforeAll(async () => { - originalHooks = (await client.getApp()).app.event_hooks; - cleanup.add(restoreHooks); + ({ original: originalHooks, restore: restoreHooks } = + await saveAndRestoreEventHooks(client, cleanup)); }); afterEach(() => vi.restoreAllMocks()); 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 3a118da..2fa17b8 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 @@ -226,11 +226,11 @@ describe(DOCS, () => { }); // 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). + // returns the user's entry as null (typed non-null). 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 + // Server-side queryChannels doesn't return `push_preferences`, even with a user_id, + // 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, diff --git a/__tests__/docs-snippets/pages/video/api/01-basics/01-get_started.agent.test.ts b/__tests__/docs-snippets/pages/video/api/01-basics/01-get_started.agent.test.ts new file mode 100644 index 0000000..00d3121 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/01-basics/01-get_started.agent.test.ts @@ -0,0 +1,29 @@ +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 appSecret = process.env.STREAM_SECRET as string; + +// #region snippet docs="video/api/01-basics/01-get_started.md" heading="Configuring Fetch API for Node SDK" tab="Node.js" index=1 +import { Agent } from 'undici'; + +const client = new StreamClient(apiKey, appSecret, { + agent: new Agent({ connections: 100 }), +}); +// #endregion snippet + +describe('video/api/01-basics/01-get_started.md (Configuring Fetch API)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls the API through the custom undici Agent', async () => { + const dispatch = vi.spyOn(Agent.prototype, 'dispatch'); + + const response = await client.getApp(); + + expect(typeof response.app.name).toBe('string'); + expect(dispatch).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/01-basics/01-get_started.client.test.ts b/__tests__/docs-snippets/pages/video/api/01-basics/01-get_started.client.test.ts new file mode 100644 index 0000000..1d5bbe9 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/01-basics/01-get_started.client.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// The docs' empty placeholders are COPY-mapped to the test app's credentials. +const streamApiKey = process.env.STREAM_API_KEY as string; +const streamSecret = process.env.STREAM_SECRET as string; + +// #region snippet docs="video/api/01-basics/01-get_started.md" heading="Creating client" tab="Node.js" index=1 +// COPY: streamApiKey="", streamSecret="" +import { StreamClient } from '@stream-io/node-sdk'; +// or +// const { StreamClient } = require("@stream-io/node-sdk"); + +const apiKey = streamApiKey; +const secret = streamSecret; +const client = new StreamClient(apiKey, secret); + +// optionally add timeout to API requests +// the default timeout is 3000ms +const clientWithTimeout = new StreamClient(apiKey, secret, { timeout: 3000 }); +// #endregion snippet + +describe('video/api/01-basics/01-get_started.md (Creating client)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a client with the default timeout', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout'); + + const response = await client.getApp(); + + expect(typeof response.app.name).toBe('string'); + expect(timeout).toHaveBeenCalledWith(3000); + }); + + it('creates a client with a custom timeout', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout'); + + const response = await clientWithTimeout.getApp(); + + expect(typeof response.app.name).toBe('string'); + expect(timeout).toHaveBeenCalledWith(3000); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/01-basics/01-get_started.test.ts b/__tests__/docs-snippets/pages/video/api/01-basics/01-get_started.test.ts new file mode 100644 index 0000000..fa81ed0 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/01-basics/01-get_started.test.ts @@ -0,0 +1,216 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { UserRequest } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'video/api/01-basics/01-get_started.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + const jackId = uniqueId('jack'); + const saraId = uniqueId('sara'); + const emilyId = uniqueId('emily'); + const firstCallId = uniqueId('my-first-call'); + const secondCallId = uniqueId('my-second-call'); + const callType = 'default'; + + beforeAll(async () => { + cleanup.users.push(johnId, jackId, saraId, emilyId); + cleanup.calls.push( + `${callType}:${firstCallId}`, + `${callType}:${secondCallId}`, + ); + await client.upsertUsers([ + { id: johnId }, + { id: jackId }, + { id: saraId }, + { id: emilyId }, + ]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a user and a user token', async () => { + const generateUserToken = vi.spyOn(client, 'generateUserToken'); + + // #region snippet docs="video/api/01-basics/01-get_started.md" heading="Creating users and user tokens" tab="Node.js" index=1 + // COPY: johnId="john" + const userId = johnId; + const newUser: UserRequest = { + id: userId, + role: 'user', + custom: { + color: 'red', + }, + name: 'John', + image: 'link/to/profile/image', + }; + await client.upsertUsers([newUser]); + + // validity is optional (by default the token is valid for an hour) + const validity = 60 * 60; + + client.generateUserToken({ + user_id: userId, + validity_in_seconds: validity, + }); + // #endregion snippet + + const { users } = await client.queryUsers({ + payload: { filter_conditions: { id: johnId } }, + }); + expect(users).toHaveLength(1); + expect(users[0]).toMatchObject({ + id: johnId, + role: 'user', + name: 'John', + image: 'link/to/profile/image', + custom: { color: 'red' }, + }); + + const token = generateUserToken.mock.results[0].value as string; + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString(), + ) as { user_id: string; iat: number; exp: number }; + expect(payload.user_id).toBe(johnId); + expect(payload.exp - payload.iat).toBe(60 * 60); + }); + + it('creates a call', async () => { + const getOrCreate = vi.spyOn(client.video, 'getOrCreateCall'); + + // #region snippet docs="video/api/01-basics/01-get_started.md" heading="Creating a call" tab="Node.js" index=1 + // COPY: firstCallId="my-first-call", secondCallId="my-second-call", johnId="john", jackId="jack" + const callType = 'default'; + const callId = firstCallId; + const call = client.video.call(callType, callId); + + await call.create({ data: { created_by_id: johnId } }); + + // optionally provide additional data + const callWithMembers = client.video.call(callType, secondCallId); + await callWithMembers.create({ + data: { + created_by_id: johnId, + // Call members need to be existing users + members: [{ user_id: johnId, role: 'admin' }, { user_id: jackId }], + custom: { + color: 'blue', + }, + }, + }); + + // Upsert behavior + await call.getOrCreate({ data: { created_by_id: johnId } }); + // #endregion snippet + + expect(getOrCreate).toHaveBeenCalledTimes(3); + const responses = await Promise.all( + getOrCreate.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + const [created, createdWithMembers, upserted] = responses; + expect(created.created).toBe(true); + expect(created.call.cid).toBe(`default:${firstCallId}`); + expect(created.call.created_by.id).toBe(johnId); + + expect(createdWithMembers.created).toBe(true); + expect(createdWithMembers.call.cid).toBe(`default:${secondCallId}`); + expect(createdWithMembers.call.custom).toEqual({ color: 'blue' }); + const roles = Object.fromEntries( + createdWithMembers.members.map((m) => [m.user_id, m.role]), + ); + expect(Object.keys(roles).sort()).toEqual([johnId, jackId].sort()); + expect(roles[johnId]).toBe('admin'); + + // getOrCreate on the existing call returns it unchanged + expect(upserted.created).toBe(false); + expect(upserted.call.cid).toBe(`default:${firstCallId}`); + }); + + it('adds and updates call members', async () => { + const call = client.video.call(callType, firstCallId); + + // #region snippet docs="video/api/01-basics/01-get_started.md" heading="Call members" tab="Node.js" index=1 + // COPY: saraId="sara", emilyId="emily" + // Call members need to be existing users + await call.updateCallMembers({ + // You can add new members + // You can also update the role of existing members + update_members: [ + { user_id: saraId }, + { user_id: emilyId, role: 'admin' }, + ], + }); + // #endregion snippet + + const { members } = await call.queryMembers(); + const roles = Object.fromEntries(members.map((m) => [m.user_id, m.role])); + expect(roles[saraId]).toBe('user'); + expect(roles[emilyId]).toBe('admin'); + }); + + it('removes call members', async () => { + const call = client.video.call(callType, firstCallId); + + // #region snippet docs="video/api/01-basics/01-get_started.md" heading="Call members" tab="Node.js" index=2 + // COPY: saraId="sara" + await call.updateCallMembers({ + remove_members: [saraId], + }); + // #endregion snippet + + const { members } = await call.queryMembers(); + const ids = members.map((m) => m.user_id); + expect(ids).not.toContain(saraId); + expect(ids).toContain(emilyId); + }); + + it('updates the call settings and custom data', async () => { + const call = client.video.call(callType, firstCallId); + const update = vi.spyOn(client.video, 'updateCall'); + + // #region snippet docs="video/api/01-basics/01-get_started.md" heading="Updating a call" tab="Node.js" index=1 + await call.update({ + settings_override: { + audio: { mic_default_on: true, default_device: 'speaker' }, + }, + }); + + // or to update custom data + await call.update({ custom: { color: 'red' } }); + // #endregion snippet + + const [settingsResponse, customResponse] = await Promise.all( + update.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(settingsResponse.call.settings.audio).toMatchObject({ + mic_default_on: true, + default_device: 'speaker', + }); + expect(customResponse.call.custom).toEqual({ color: 'red' }); + + const { call: stored } = await call.get(); + expect(stored.settings.audio.mic_default_on).toBe(true); + expect(stored.custom).toEqual({ color: 'red' }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/01-basics/03-calls.test.ts b/__tests__/docs-snippets/pages/video/api/01-basics/03-calls.test.ts new file mode 100644 index 0000000..608f5d6 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/01-basics/03-calls.test.ts @@ -0,0 +1,675 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { OwnCapability } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'video/api/01-basics/03-calls.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + const jackId = uniqueId('jack'); + const saraId = uniqueId('sara'); + const emilyId = uniqueId('emily'); + const myselfId = uniqueId('myself'); + const friendId = uniqueId('friend'); + const pinUserId = uniqueId('pin'); + const unpinUserId = uniqueId('unpin'); + + /** A `default` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createCall = async ( + data: Parameters['create']>[0] = {}, + ) => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.create({ + ...data, + data: { created_by_id: johnId, ...data.data }, + }); + return call; + }; + + /** + * A call type with a `uniqueId` name (same grants as `default`), deleted in cleanup. + * Waits until it can be read, so the snippets can update it. + */ + const createCallType = async () => { + const name = uniqueId('call-type'); + await client.video.createCallType({ name }); + // A new call type isn't known to every API node right away ("does not exist"). + const isMissing = (error: unknown) => + String(error).includes('does not exist'); + cleanup.add(() => + retry(() => client.video.deleteCallType({ name }), { + retryIf: isMissing, + }), + ); + for (let i = 0; i < 5; i++) { + await retry(() => client.video.getCallType({ name }), { + retryIf: isMissing, + }); + } + return name; + }; + + beforeAll(async () => { + const ids = [ + johnId, + jackId, + saraId, + emilyId, + myselfId, + friendId, + pinUserId, + unpinUserId, + ]; + cleanup.users.push(...ids); + await client.upsertUsers(ids.map((id) => ({ id }))); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates calls', async () => { + const firstCallId = uniqueId('my-first-call'); + const secondCallId = uniqueId('my-second-call'); + cleanup.calls.push(`default:${firstCallId}`, `default:${secondCallId}`); + const getOrCreate = vi.spyOn(client.video, 'getOrCreateCall'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Creating calls" tab="Node.js" index=1 + // COPY: firstCallId="my-first-call", secondCallId="my-second-call", johnId="john", jackId="jack" + const callType = 'default'; + const callId = firstCallId; + const call = client.video.call(callType, callId); + + await call.create({ data: { created_by_id: johnId } }); + + // optionally provide additional data + const callWithMembers = client.video.call(callType, secondCallId); + await callWithMembers.create({ + data: { + created_by_id: johnId, + // Call members need to be existing users + members: [{ user_id: johnId, role: 'admin' }, { user_id: jackId }], + custom: { + color: 'blue', + }, + }, + }); + + // Upsert behavior + await call.getOrCreate({ data: { created_by_id: johnId } }); + // #endregion snippet + + const [created, createdWithMembers, upserted] = await Promise.all( + getOrCreate.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(created.created).toBe(true); + expect(created.call.cid).toBe(`default:${firstCallId}`); + expect(created.call.created_by.id).toBe(johnId); + + expect(createdWithMembers.created).toBe(true); + expect(createdWithMembers.call.custom).toEqual({ color: 'blue' }); + const roles = Object.fromEntries( + createdWithMembers.members.map((m) => [m.user_id, m.role]), + ); + expect(Object.keys(roles).sort()).toEqual([johnId, jackId].sort()); + expect(roles[johnId]).toBe('admin'); + + expect(upserted.created).toBe(false); + expect(upserted.call.cid).toBe(`default:${firstCallId}`); + }); + + it('creates a ringing call', async () => { + // #region snippet docs="video/api/01-basics/03-calls.md" heading="`ring` flag" tab="Node.js" index=1 + // COPY: myselfId="myself", friendId="my friend" + // Make sure to use a unique id when creating ring calls + const call = client.video.call('default', crypto.randomUUID()); + await call.getOrCreate({ + ring: true, + video: true, // Set it to `false` for audio calls. This is used to decide what type of incoming call screen is displayed client-side + data: { + created_by_id: myselfId, + members: [{ user_id: myselfId }, { user_id: friendId }], + }, + }); + // #endregion snippet + cleanup.calls.push(call.cid); + + const { call: stored, members } = await call.get(); + expect(stored.created_by.id).toBe(myselfId); + expect(members.map((m) => m.user_id).sort()).toEqual( + [myselfId, friendId].sort(), + ); + }); + + it('creates a call and notifies the members', async () => { + const outgoingCallId = uniqueId('test-outgoing-call'); + cleanup.calls.push(`default:${outgoingCallId}`); + const getOrCreate = vi.spyOn(client.video, 'getOrCreateCall'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="`notify` flag" tab="Node.js" index=1 + // COPY: outgoingCallId="test-outgoing-call", myselfId="myself", friendId="my friend" + // or call.create + // or call.get + await client.video.call('default', outgoingCallId).getOrCreate({ + notify: true, + data: { + created_by_id: myselfId, + members: [{ user_id: myselfId }, { user_id: friendId }], + }, + }); + // #endregion snippet + + expect(getOrCreate.mock.calls[0][0]).toMatchObject({ notify: true }); + const response = await (getOrCreate.mock.results[0].value as ReturnType< + typeof client.video.getOrCreateCall + >); + expect(response.created).toBe(true); + expect(response.members.map((m) => m.user_id).sort()).toEqual( + [myselfId, friendId].sort(), + ); + }); + + it('updates a call', async () => { + const call = await createCall(); + const update = vi.spyOn(client.video, 'updateCall'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Updating calls" tab="Node.js" index=1 + await call.update({ + settings_override: { + audio: { mic_default_on: true, default_device: 'speaker' }, + }, + }); + + // or to update custom data + await call.update({ custom: { color: 'red' } }); + // #endregion snippet + + expect(update).toHaveBeenCalledTimes(2); + const { call: stored } = await call.get(); + expect(stored.settings.audio).toMatchObject({ + mic_default_on: true, + default_device: 'speaker', + }); + expect(stored.custom).toEqual({ color: 'red' }); + }); + + it('adds and updates call members', async () => { + const call = await createCall(); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Manage call members" tab="Node.js" index=1 + // COPY: saraId="sara", emilyId="emily" + // Call members need to be existing users + await call.updateCallMembers({ + // You can add new members + // You can also update the role of existing members + update_members: [ + { user_id: saraId }, + { user_id: emilyId, role: 'admin' }, + ], + }); + // #endregion snippet + + const { members } = await call.queryMembers(); + const roles = Object.fromEntries(members.map((m) => [m.user_id, m.role])); + expect(roles).toEqual({ [saraId]: 'user', [emilyId]: 'admin' }); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Manage call members" tab="Node.js" index=2 + // COPY: saraId="sara" + await call.updateCallMembers({ + remove_members: [saraId], + }); + // #endregion snippet + + const { members: remaining } = await call.queryMembers(); + expect(remaining.map((m) => m.user_id)).toEqual([emilyId]); + }); + + it('restricts call access to members', async () => { + const restrictedCallType = await createCallType(); + const before = (await client.video.listCallTypes()).call_types[ + restrictedCallType + ]; + expect(before.grants['user']).toContain(OwnCapability.JOIN_CALL); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Restrict call access" tab="Node.js" index=1 + // COPY: restrictedCallType="default" + const callTypeName = restrictedCallType; + + const callType = (await client.video.listCallTypes()).call_types[ + callTypeName + ]; + // Remove JOIN_CALL permission from user role + const userGrants = callType.grants['user'].filter( + (c) => c !== OwnCapability.JOIN_CALL, + ); + // Make sure JOIN_CALL permission is set for call_member role + const callMemberGrants = callType.grants['call_member']; + if (!callMemberGrants.includes(OwnCapability.JOIN_CALL)) { + callMemberGrants.push(OwnCapability.JOIN_CALL); + } + + // Update the call type with the changes + await client.video.updateCallType({ + name: callTypeName, + grants: { + user: userGrants, + call_member: callMemberGrants, + }, + }); + // #endregion snippet + + const { grants } = await client.video.getCallType({ + name: restrictedCallType, + }); + expect(grants['user']).not.toContain(OwnCapability.JOIN_CALL); + expect(grants['user'].length).toBeGreaterThan(0); + expect(grants['call_member']).toContain(OwnCapability.JOIN_CALL); + }); + + it('configures a custom session timeout', async () => { + const sessionCallType = await createCallType(); + const update = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Configuring custom session timeout" tab="Node.js" index=1 + // COPY: sessionCallType="default" + await client.video.updateCallType({ + name: sessionCallType, + settings: { + session: { + inactivity_timeout_seconds: 300, + }, + }, + }); + // #endregion snippet + + const response = await (update.mock.results[0].value as ReturnType< + typeof client.video.updateCallType + >); + expect(response.settings.session.inactivity_timeout_seconds).toBe(300); + }); + + it('ends a call', async () => { + const call = await createCall(); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Ending calls" tab="Node.js" index=1 + await call.end(); + // #endregion snippet + + const { call: stored } = await call.get(); + expect(stored.ended_at).toBeInstanceOf(Date); + }); + + it('queries calls with sorting and pagination', async () => { + const queryCalls = vi.spyOn(client.video, 'queryCalls'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Sorting and pagination" tab="Node.js" index=1 + // default sorting + await client.video.queryCalls(); + + // sorting and pagination + const queryCallsReq = { + sort: [{ field: 'starts_at', direction: -1 }], + limit: 2, + }; + const response = await client.video.queryCalls(queryCallsReq); + + // loading next page + await client.video.queryCalls({ + ...queryCallsReq, + next: response.next, + }); + // #endregion snippet + + expect(queryCalls).toHaveBeenCalledTimes(3); + const [all, , nextPage] = await Promise.all( + queryCalls.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(all.calls.length).toBeGreaterThan(2); + expect(response.calls).toHaveLength(2); + expect(typeof response.next).toBe('string'); + // The second page can come back empty when the sort values are null (see the report). + expect(Array.isArray(nextPage.calls)).toBe(true); + expect(queryCalls.mock.calls[2][0]).toMatchObject({ + sort: [{ field: 'starts_at', direction: -1 }], + limit: 2, + next: response.next, + }); + }); + + it('queries live calls', async () => { + // `backstage: false` only matches calls that went live: a call of a type without + // backstage (`default`) isn't matched, so use a `livestream` call and go live. + const liveCall = client.video.call('livestream', uniqueId('live-call')); + cleanup.calls.push(liveCall.cid); + await liveCall.create({ data: { created_by_id: johnId } }); + await liveCall.goLive(); + const defaultCall = await createCall(); + const queryCalls = vi.spyOn(client.video, 'queryCalls'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Query live calls" tab="Node.js" index=1 + await client.video.queryCalls({ + filter_conditions: { backstage: { $eq: false } }, + }); + // #endregion snippet + + const { calls } = await (queryCalls.mock.results[0].value as ReturnType< + typeof client.video.queryCalls + >); + const cids = calls.map((c) => c.call.cid); + expect(cids).toContain(liveCall.cid); + expect(cids).not.toContain(defaultCall.cid); + expect(calls.every((c) => c.call.backstage === false)).toBe(true); + }); + + it('queries upcoming calls', async () => { + await createCall({ + data: { starts_at: new Date(Date.now() + 1000 * 60 * 60) }, + }); + const queryCalls = vi.spyOn(client.video, 'queryCalls'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Query upcoming calls" tab="Node.js" index=1 + const mins30 = 1000 * 60 * 30; + const inNext30mins = new Date(Date.now() + mins30); + await client.video.queryCalls({ + filter_conditions: { + starts_at: { $gt: inNext30mins.toISOString() }, + }, + }); + // #endregion snippet + + const { calls } = await (queryCalls.mock.results[0].value as ReturnType< + typeof client.video.queryCalls + >); + expect(calls.length).toBeGreaterThan(0); + for (const { call } of calls) { + expect(call.starts_at?.getTime()).toBeGreaterThan(inNext30mins.getTime()); + } + }); + + it('queries ongoing calls', async () => { + const queryCalls = vi.spyOn(client.video, 'queryCalls'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Query ongoing calls" tab="Node.js" index=1 + await client.video.queryCalls({ + filter_conditions: { ongoing: { $eq: true } }, + }); + // #endregion snippet + + // No call has a live session on the test app (no WebRTC participants). + const { calls } = await (queryCalls.mock.results[0].value as ReturnType< + typeof client.video.queryCalls + >); + expect(Array.isArray(calls)).toBe(true); + }); + + it('queries call members', async () => { + const call = await createCall({ + data: { + members: [ + { user_id: saraId }, + { user_id: emilyId, role: 'admin' }, + { user_id: jackId }, + ], + }, + }); + const queryMembers = vi.spyOn(client.video, 'queryCallMembers'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Query call members" tab="Node.js" index=1 + // default sorting + await call.queryMembers(); + + // sorting and pagination + const queryMembersReq = { + sort: [{ field: 'user_id', direction: 1 }], + limit: 2, + }; + const response = await call.queryMembers(queryMembersReq); + + // loading next page + await call.queryMembers({ + ...queryMembersReq, + next: response.next, + }); + + // filtering + await call.queryMembers({ + filter_conditions: { role: { $eq: 'admin' } }, + }); + // #endregion snippet + + const [all, , nextPage, admins] = await Promise.all( + queryMembers.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + const sorted = [saraId, emilyId, jackId].sort(); + expect(all.members.map((m) => m.user_id).sort()).toEqual(sorted); + expect(response.members.map((m) => m.user_id)).toEqual(sorted.slice(0, 2)); + expect(nextPage.members.map((m) => m.user_id)).toEqual(sorted.slice(2)); + expect(admins.members.map((m) => m.user_id)).toEqual([emilyId]); + }); + + it('queries call participants', async () => { + const queryParticipants = vi.spyOn(client.video, 'queryCallParticipants'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Query call Participants" tab="Node.js" index=1 + // COPY: saraId="sara" + const call = client.video.call('default', crypto.randomUUID()); + await call.getOrCreate({ + data: { + created_by_id: saraId, + }, + }); + + const response = await call.queryCallParticipants({ + filter_conditions: { + user_id: { $eq: 'user-id' }, + }, + }); + + console.log('Total participants:', response.total_participants); + console.log('Role:', response.participants[0]?.user?.role); + + // Filter by published track + await call.queryCallParticipants({ + filter_conditions: { + published_tracks: { $eq: 'video' }, + }, + }); + + // Filter multiple users + await call.queryCallParticipants({ + filter_conditions: { + user_id: { $in: ['user-id-1', 'user-id-2'] }, + }, + limit: 100, + }); + // #endregion snippet + cleanup.calls.push(call.cid); + + expect(log.mock.calls).toEqual([ + ['Total participants:', 0], + ['Role:', undefined], + ]); + expect(queryParticipants).toHaveBeenCalledTimes(3); + expect(queryParticipants.mock.calls[1][0]).toMatchObject({ + filter_conditions: { published_tracks: { $eq: 'video' } }, + }); + expect(queryParticipants.mock.calls[2][0]).toMatchObject({ limit: 100 }); + const [, byTrack, byUsers] = await Promise.all( + queryParticipants.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + // No one joins the call over WebRTC in the tests. + expect(response.call.cid).toBe(call.cid); + expect(byTrack.participants).toEqual([]); + expect(byUsers.total_participants).toBe(0); + expect(byUsers.participants).toEqual([]); + }); + + it('sends a custom event', async () => { + const call = await createCall(); + const sendCallEvent = vi.spyOn(client.video, 'sendCallEvent'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Send custom event" tab="Node.js" index=1 + // COPY: johnId="john" + // send a custom event to all users watching the call + await call.sendCallEvent({ + custom: { + 'render-animation': 'balloons', + }, + user_id: johnId, + }); + // #endregion snippet + + const response = await (sendCallEvent.mock.results[0].value as ReturnType< + typeof client.video.sendCallEvent + >); + expect(response.metadata.responseCode).toBe(201); + }); + + it('pins and unpins video', async () => { + const call = await createCall(); + const pin = vi.spyOn(client.video, 'videoPin'); + const unpin = vi.spyOn(client.video, 'videoUnpin'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Pin and unpin video" tab="Node.js" index=1 + // COPY: pinUserId="user-id-to-pin", unpinUserId="user-id-to-unpin" + await call.videoPin({ + session_id: 'session-id', + user_id: pinUserId, + }); + + await call.videoUnpin({ + session_id: 'session-id', + user_id: unpinUserId, + }); + // #endregion snippet + + // The API accepts any session id: there is no participant to pin without WebRTC. + const [pinned, unpinned] = await Promise.all([ + pin.mock.results[0].value as ReturnType, + unpin.mock.results[0].value as ReturnType, + ]); + expect(pinned.metadata.responseCode).toBe(201); + expect(unpinned.metadata.responseCode).toBe(201); + }); + + it('sets and disables the session timer on a call type', async () => { + const timerCallType = await createCallType(); + const update = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Call type configuration" tab="Node.js" index=1 + // COPY: timerCallType="default" + await client.video.updateCallType({ + name: timerCallType, + settings: { + limits: { + max_duration_seconds: 3600, + }, + }, + }); + + // Disable the default session timer + await client.video.updateCallType({ + name: timerCallType, + settings: { + limits: { + max_duration_seconds: 0, + }, + }, + }); + // #endregion snippet + + const [enabled, disabled] = await Promise.all( + update.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(enabled.settings.limits.max_duration_seconds).toBe(3600); + expect(disabled.settings.limits.max_duration_seconds ?? 0).toBe(0); + }); + + it('creates a call with a session timer', async () => { + const outgoingCallId = uniqueId('test-outgoing-call'); + cleanup.calls.push(`default:${outgoingCallId}`); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Create call with session timer" tab="Node.js" index=1 + // COPY: outgoingCallId="test-outgoing-call", johnId="john" + // or call.create + await client.video.call('default', outgoingCallId).getOrCreate({ + data: { + created_by_id: johnId, + settings_override: { + limits: { + max_duration_seconds: 3600, + }, + }, + }, + }); + // #endregion snippet + + const { call } = await client.video.call('default', outgoingCallId).get(); + expect(call.settings.limits.max_duration_seconds).toBe(3600); + }); + + it('extends and disables the session timer of a call', async () => { + const call = await createCall({ + data: { settings_override: { limits: { max_duration_seconds: 3600 } } }, + }); + const update = vi.spyOn(client.video, 'updateCall'); + + // #region snippet docs="video/api/01-basics/03-calls.md" heading="Updating call object to extend session time" tab="Node.js" index=1 + // Update the call with session timer + const { call: callData } = await call.get(); + await call.update({ + settings_override: { + limits: { + max_duration_seconds: + (callData.settings.limits.max_duration_seconds ?? 0) + 300, + }, + }, + }); + + // Disable the session timer + await call.update({ + settings_override: { + limits: { + max_duration_seconds: 0, + }, + }, + }); + // #endregion snippet + + const [extended, disabled] = await Promise.all( + update.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(extended.call.settings.limits.max_duration_seconds).toBe(3900); + expect(disabled.call.settings.limits.max_duration_seconds ?? 0).toBe(0); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/01-basics/04-ring-calls.test.ts b/__tests__/docs-snippets/pages/video/api/01-basics/04-ring-calls.test.ts new file mode 100644 index 0000000..bc6c547 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/01-basics/04-ring-calls.test.ts @@ -0,0 +1,220 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + createCallType, + trackCallTypeCreates, +} from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'video/api/01-basics/04-ring-calls.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const myselfId = uniqueId('myself'); + const friendId = uniqueId('friend'); + const otherFriendId = uniqueId('other-friend'); + + beforeAll(async () => { + const ids = [myselfId, friendId, otherFriendId]; + cleanup.users.push(...ids); + await client.upsertUsers(ids.map((id) => ({ id }))); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a ring call', async () => { + const getOrCreate = vi.spyOn(client.video, 'getOrCreateCall'); + + // #region snippet docs="video/api/01-basics/04-ring-calls.md" heading="Creating ring calls" tab="Node.js" index=1 + // COPY: myselfId="myself", friendId="my friend" + // Make sure to use a unique id when creating ring calls + const call = client.video.call('default', crypto.randomUUID()); + await call.getOrCreate({ + ring: true, + video: true, // Set it to `false` for audio calls. This is used to decide what type of incoming call screen is displayed client-side + data: { + created_by_id: myselfId, + members: [{ user_id: myselfId }, { user_id: friendId }], + }, + }); + // #endregion snippet + cleanup.calls.push(call.cid); + + expect(getOrCreate.mock.calls[0][0]).toMatchObject({ + ring: true, + video: true, + }); + const response = await (getOrCreate.mock.results[0].value as ReturnType< + typeof client.video.getOrCreateCall + >); + expect(response.created).toBe(true); + expect(response.call.created_by.id).toBe(myselfId); + expect(response.members.map((m) => m.user_id).sort()).toEqual( + [myselfId, friendId].sort(), + ); + }); + + it('rings individual members', async () => { + const ring = vi.spyOn(client.video, 'ringCall'); + + // #region snippet docs="video/api/01-basics/04-ring-calls.md" heading="Ringing individual members" tab="Node.js" index=1 + // COPY: myselfId="myself", friendId="my-friend", otherFriendId="my-other-friend" + // Make sure to use a unique id when creating ring calls + const call = client.video.call('default', crypto.randomUUID()); + await call.getOrCreate({ + ring: false, // set to false to avoid ringing the whole call + data: { + created_by_id: myselfId, + members: [{ user_id: myselfId }, { user_id: friendId }], + }, + }); + + // note: my-friend needs to be a member of the call + await call.ring({ members_ids: [friendId] }); + + // to invite a new member and ring them + await call.updateCallMembers({ + update_members: [{ user_id: otherFriendId }], + }); + await call.ring({ members_ids: [otherFriendId] }); + + // to ring everyone + await call.ring(); + // #endregion snippet + cleanup.calls.push(call.cid); + + const [first, second, everyone] = await Promise.all( + ring.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(first.members_ids).toEqual([friendId]); + expect(second.members_ids).toEqual([otherFriendId]); + // Everyone except the caller, who is already "in" the call. + expect([...everyone.members_ids].sort()).toEqual( + [friendId, otherFriendId].sort(), + ); + }); + + it('sets ring timeouts', async () => { + const callTypeName = await createCallType(client, cleanup); + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.create({ data: { created_by_id: myselfId } }); + + // #region snippet docs="video/api/01-basics/04-ring-calls.md" heading="Timeouts" tab="Node.js" index=1 + // on call type level + await client.video.updateCallType({ + name: callTypeName, + settings: { + ring: { + incoming_call_timeout_ms: 10000, + auto_cancel_timeout_ms: 15000, + missed_call_timeout_ms: 15000, + }, + }, + }); + + // or on call level + await call.update({ + settings_override: { + ring: { + incoming_call_timeout_ms: 10000, + auto_cancel_timeout_ms: 15000, + missed_call_timeout_ms: 15000, + }, + }, + }); + // #endregion snippet + + const expected = { + incoming_call_timeout_ms: 10000, + auto_cancel_timeout_ms: 15000, + missed_call_timeout_ms: 15000, + }; + const callType = await client.video.getCallType({ name: callTypeName }); + expect(callType.settings.ring).toMatchObject(expected); + const { call: stored } = await call.get(); + expect(stored.settings.ring).toMatchObject(expected); + }); + + it('configures push notifications on a call type', async () => { + const pushCallType = uniqueId('call-type'); + const create = trackCallTypeCreates(client, cleanup); + + // #region snippet docs="video/api/01-basics/04-ring-calls.md" heading="Push notifications" tab="Node.js" index=1 + // COPY: pushCallType="" + await client.video.createCallType({ + name: pushCallType, + notification_settings: { + enabled: true, + call_notification: { + apns: { + title: '{{ user.display_name }} calls you', + body: '{{ user.display_name }} calls you', + }, + enabled: true, + }, + call_ring: { + apns: { + title: '{{ user.display_name }} calls you', + body: '{{ user.display_name }} calls you', + }, + enabled: true, + }, + call_live_started: { + enabled: true, + apns: { + title: '{{ call.display_name }} started', + body: '{{ user.display_name }} started', + }, + }, + call_missed: { + enabled: true, + apns: { + title: 'missed call from {{ user.display_name }}', + body: 'missed call from {{ user.display_name }}', + }, + }, + session_started: { + enabled: true, + apns: { + title: '{{ call.display_name }} started', + body: '{{ call.display_name }} started', + }, + }, + }, + }); + // #endregion snippet + + expect(create).toHaveBeenCalledTimes(1); + const { notification_settings } = await client.video.getCallType({ + name: pushCallType, + }); + expect(notification_settings.enabled).toBe(true); + expect(notification_settings.call_ring).toMatchObject({ + enabled: true, + apns: { title: '{{ user.display_name }} calls you' }, + }); + expect(notification_settings.call_missed).toMatchObject({ + enabled: true, + apns: { body: 'missed call from {{ user.display_name }}' }, + }); + expect(notification_settings.session_started.enabled).toBe(true); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/02-call_types/02-manage-types.test.ts b/__tests__/docs-snippets/pages/video/api/02-call_types/02-manage-types.test.ts new file mode 100644 index 0000000..8fc931b --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/02-call_types/02-manage-types.test.ts @@ -0,0 +1,126 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { OwnCapability } from '../../../../../../index'; +import { trackCallTypeCreates } from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'video/api/02-call_types/02-manage-types.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + /** The docs' "allhands" type: created by the "Create call type" fence. */ + const allhandsType = uniqueId('allhands'); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reads call types', async () => { + const list = vi.spyOn(client.video, 'listCallTypes'); + const get = vi.spyOn(client.video, 'getCallType'); + + // #region snippet docs="video/api/02-call_types/02-manage-types.md" heading="Read call types" tab="Node.js" index=1 + await client.video.listCallTypes(); + + //or + await client.video.getCallType({ name: 'livestream' }); + // #endregion snippet + + const { call_types } = await (list.mock.results[0].value as ReturnType< + typeof client.video.listCallTypes + >); + expect(Object.keys(call_types)).toEqual( + expect.arrayContaining(['default', 'livestream']), + ); + const livestream = await (get.mock.results[0].value as ReturnType< + typeof client.video.getCallType + >); + expect(livestream.name).toBe('livestream'); + }); + + it('creates a call type', async () => { + const create = trackCallTypeCreates(client, cleanup); + + // #region snippet docs="video/api/02-call_types/02-manage-types.md" heading="Create call type" tab="Node.js" index=1 + // COPY: allhandsType="allhands" + await client.video.createCallType({ + name: allhandsType, + settings: { + audio: { + mic_default_on: true, + default_device: 'speaker', + }, + }, + grants: { + admin: [ + OwnCapability.SEND_AUDIO, + OwnCapability.SEND_VIDEO, + OwnCapability.MUTE_USERS, + ], + user: [OwnCapability.SEND_AUDIO, OwnCapability.SEND_VIDEO], + }, + }); + // #endregion snippet + + expect(create).toHaveBeenCalledTimes(1); + const callType = await client.video.getCallType({ name: allhandsType }); + expect(callType.settings.audio).toMatchObject({ + mic_default_on: true, + default_device: 'speaker', + }); + expect([...callType.grants.admin].sort()).toEqual( + ['send-audio', 'send-video', 'mute-users'].sort(), + ); + expect([...callType.grants.user].sort()).toEqual( + ['send-audio', 'send-video'].sort(), + ); + }); + + it('updates a call type', async () => { + const update = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/02-call_types/02-manage-types.md" heading="Update call type" tab="Node.js" index=1 + // COPY: allhandsType="allhands" + await client.video.updateCallType({ + name: allhandsType, + settings: { + audio: { + mic_default_on: false, + default_device: 'earpiece', + }, + }, + }); + // #endregion snippet + + const response = await (update.mock.results[0].value as ReturnType< + typeof client.video.updateCallType + >); + expect(response.settings.audio).toMatchObject({ + mic_default_on: false, + default_device: 'earpiece', + }); + // Grants are kept. + expect([...response.grants.user].sort()).toEqual( + ['send-audio', 'send-video'].sort(), + ); + }); + + it('deletes a call type', async () => { + // #region snippet docs="video/api/02-call_types/02-manage-types.md" heading="Delete call type" tab="Node.js" index=1 + // COPY: allhandsType="allhands" + await client.video.deleteCallType({ name: allhandsType }); + // #endregion snippet + + // Reads can lag behind on other API nodes. + await retry(async () => { + const { call_types } = await client.video.listCallTypes(); + expect(call_types[allhandsType]).toBeUndefined(); + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/02-call_types/03-geofencing.test.ts b/__tests__/docs-snippets/pages/video/api/02-call_types/03-geofencing.test.ts new file mode 100644 index 0000000..bd4938b --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/02-call_types/03-geofencing.test.ts @@ -0,0 +1,87 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + retryOnCallTypeMissing, + trackCallTypeCreates, +} from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'video/api/02-call_types/03-geofencing.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 }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets geofences on a call type and a call', async () => { + const geoCallType = uniqueId('call-type'); + const call = client.video.call(geoCallType, uniqueId('call')); + const create = trackCallTypeCreates(client, cleanup); + // Other API nodes can still answer "call type does not exist" for the new type. + const originalGetOrCreate = client.video.getOrCreateCall.bind(client.video); + const getOrCreate = vi + .spyOn(client.video, 'getOrCreateCall') + .mockImplementation((request) => + retryOnCallTypeMissing(() => originalGetOrCreate(request)), + ); + + // #region snippet docs="video/api/02-call_types/03-geofencing.md" heading="Exclusion Geofences" tab="Node.js" index=1 + // COPY: geoCallType="", johnId="john" + await client.video.createCallType({ + name: geoCallType, + settings: { + geofencing: { + names: ['european_union'], + }, + }, + }); + + //override settings on call level + await call.create({ + data: { + created_by_id: johnId, + settings_override: { + geofencing: { + names: ['european_union', 'united_states'], + }, + }, + }, + }); + // #endregion snippet + cleanup.calls.push(call.cid); + + const callType = await (create.mock.results[0].value as ReturnType< + typeof client.video.createCallType + >); + expect(callType.settings.geofencing.names).toEqual(['european_union']); + const created = await (getOrCreate.mock.results[0].value as ReturnType< + typeof client.video.getOrCreateCall + >); + expect(created.created).toBe(true); + expect(created.call.settings.geofencing.names).toEqual([ + 'european_union', + 'united_states', + ]); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/02-call_types/03-permissions.test.ts b/__tests__/docs-snippets/pages/video/api/02-call_types/03-permissions.test.ts new file mode 100644 index 0000000..aa970be --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/02-call_types/03-permissions.test.ts @@ -0,0 +1,129 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { + createCallType, + trackCallTypeCreates, +} from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'video/api/02-call_types/03-permissions.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('configures roles on a call type', async () => { + const rolesCallType = uniqueId('call-type'); + // The docs' "edit a built-in call type": run on a test call type instead. + const defaultCallType = await createCallType(client, cleanup); + // "you need to create the `customrole` beforehand" + const customRole = uniqueId('customrole'); + await client.createRole({ name: customRole }); + // The role can't be deleted while a call type grants it, and + // `cleanup.callTypes` runs after the custom steps: delete the fence's call + // type first ("does not exist": not created, not propagated yet, or gone). + cleanup.add(async () => { + await retry(() => client.video.deleteCallType({ name: rolesCallType }), { + timeout: 20000, + retryIf: (error) => String(error).includes('does not exist'), + }).catch(() => undefined); + await retry(() => client.deleteRole({ name: customRole }), { + retryIf: (error) => + String(error).includes('does not exist') || + String(error).includes('present in permission grants'), + }); + }); + // A just-created custom role isn't known to every API node right away + // ("cannot use unknown role"): retry the tracked create on that. + const create = trackCallTypeCreates(client, cleanup); + const trackedCreate = create.getMockImplementation()!; + create.mockImplementation((request) => + retry(() => trackedCreate(request), { + retryIf: (error) => String(error).includes('unknown role'), + }), + ); + const update = vi.spyOn(client.video, 'updateCallType'); + const original = await client.video.getCallType({ + name: defaultCallType, + }); + + // #region snippet docs="video/api/02-call_types/03-permissions.md" heading="Configuring roles" tab="Node.js" index=1 + // COPY: rolesCallType="", customRole="customrole", defaultCallType="default" + await client.video.createCallType({ + name: rolesCallType, + grants: { + admin: ['send-audio', 'send-video', 'mute-users'], + [customRole]: ['send-audio', 'send-video'], + }, + }); + + // or edit a built-in call type + await client.video.updateCallType({ + name: defaultCallType, + grants: {/* ... */}, + }); + // #endregion snippet + + const { grants } = await client.video.getCallType({ name: rolesCallType }); + expect([...grants.admin].sort()).toEqual( + ['send-audio', 'send-video', 'mute-users'].sort(), + ); + expect([...grants[customRole]].sort()).toEqual( + ['send-audio', 'send-video'].sort(), + ); + + // An empty `grants` object changes nothing. + const updated = await (update.mock.results[0].value as ReturnType< + typeof client.video.updateCallType + >); + expect(updated.name).toBe(defaultCallType); + expect(updated.grants).toEqual(original.grants); + }); + + it('lists call types and their roles', async () => { + const list = vi.spyOn(client.video, 'listCallTypes'); + const get = vi.spyOn(client.video, 'getCallType'); + + // #region snippet docs="video/api/02-call_types/03-permissions.md" heading="Built-in roles" tab="Node.js" index=1 + await client.video.listCallTypes(); + + //or + await client.video.getCallType({ name: 'livestream' }); + // #endregion snippet + + const { call_types } = await (list.mock.results[0].value as ReturnType< + typeof client.video.listCallTypes + >); + expect(call_types.default.grants).toHaveProperty('admin'); + const livestream = await (get.mock.results[0].value as ReturnType< + typeof client.video.getCallType + >); + expect(livestream.name).toBe('livestream'); + expect(Object.keys(livestream.grants)).toEqual( + expect.arrayContaining(['user', 'admin', 'host']), + ); + }); + + it('lists permissions', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="video/api/02-call_types/03-permissions.md" heading="Permissions" tab="Node.js" index=1 + const response = await client.listPermissions(); + console.log(response.permissions); + // #endregion snippet + + expect(log).toHaveBeenCalledWith(response.permissions); + const ids = response.permissions.map((p) => p.id); + expect(ids).toEqual( + expect.arrayContaining(['send-audio', 'send-video', 'mute-users']), + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/02-call_types/05-settings.test.ts b/__tests__/docs-snippets/pages/video/api/02-call_types/05-settings.test.ts new file mode 100644 index 0000000..d447d15 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/02-call_types/05-settings.test.ts @@ -0,0 +1,155 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + retryOnCallTypeMissing, + trackCallTypeCreates, +} from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'video/api/02-call_types/05-settings.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 }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets screensharing settings on a call type and a call', async () => { + const settingsCallType = uniqueId('call-type'); + const call = client.video.call(settingsCallType, uniqueId('call')); + const create = trackCallTypeCreates(client, cleanup); + // Other API nodes can still answer "call type does not exist" for the new type. + const originalGetOrCreate = client.video.getOrCreateCall.bind(client.video); + const getOrCreate = vi + .spyOn(client.video, 'getOrCreateCall') + .mockImplementation((request) => + retryOnCallTypeMissing(() => originalGetOrCreate(request)), + ); + + // #region snippet docs="video/api/02-call_types/05-settings.md" heading="Settings" tab="Node.js" index=1 + // COPY: settingsCallType="", johnId="john" + await client.video.createCallType({ + name: settingsCallType, + settings: { + screensharing: { + access_request_enabled: false, + enabled: true, + }, + }, + }); + + // override settings on call level + await call.create({ + data: { + created_by_id: johnId, + settings_override: { + screensharing: { + enabled: false, + }, + }, + }, + }); + // #endregion snippet + cleanup.calls.push(call.cid); + + const callType = await (create.mock.results[0].value as ReturnType< + typeof client.video.createCallType + >); + expect(callType.settings.screensharing).toMatchObject({ + access_request_enabled: false, + enabled: true, + }); + const created = await (getOrCreate.mock.results[0].value as ReturnType< + typeof client.video.getOrCreateCall + >); + expect(created.created).toBe(true); + expect(created.call.settings.screensharing).toMatchObject({ + access_request_enabled: false, + enabled: false, + }); + }); + + it('sets notification settings on a call type', async () => { + const notificationCallType = uniqueId('call-type'); + const create = trackCallTypeCreates(client, cleanup); + + // #region snippet docs="video/api/02-call_types/05-settings.md" heading="Notification settings" tab="Node.js" index=1 + // COPY: notificationCallType="" + await client.video.createCallType({ + name: notificationCallType, + notification_settings: { + enabled: true, + call_notification: { + apns: { + title: '{{ user.display_name }} calls you', + body: '{{ user.display_name }} calls you', + }, + enabled: true, + }, + call_ring: { + apns: { + title: '{{ user.display_name }} calls you', + body: '{{ user.display_name }} calls you', + }, + enabled: true, + }, + call_live_started: { + enabled: true, + apns: { + title: '{{ call.display_name }} started', + body: '{{ user.display_name }} started', + }, + }, + call_missed: { + enabled: true, + apns: { + title: 'missed call from {{ user.display_name }}', + body: 'missed call from {{ user.display_name }}', + }, + }, + session_started: { + enabled: true, + apns: { + title: '{{ call.display_name }} started', + body: '{{ call.display_name }} started', + }, + }, + }, + }); + // #endregion snippet + + expect(create).toHaveBeenCalledTimes(1); + const { notification_settings } = await client.video.getCallType({ + name: notificationCallType, + }); + expect(notification_settings.enabled).toBe(true); + expect(notification_settings.call_notification).toMatchObject({ + enabled: true, + apns: { title: '{{ user.display_name }} calls you' }, + }); + expect(notification_settings.call_live_started).toMatchObject({ + enabled: true, + apns: { title: '{{ call.display_name }} started' }, + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/03-streaming/01-overview.test.ts b/__tests__/docs-snippets/pages/video/api/03-streaming/01-overview.test.ts new file mode 100644 index 0000000..426df1a --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/03-streaming/01-overview.test.ts @@ -0,0 +1,137 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { StreamCall } from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'video/api/03-streaming/01-overview.md'; + +/** Reads the JSON payload of a JWT. */ +const jwtPayload = (token: string) => + JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString('utf8'), + ) as Record; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + const janeId = uniqueId('jane'); + const callId = uniqueId('livestream'); + /** The call the quickstart creates; the later fences use it. */ + let liveCall: StreamCall; + + beforeAll(async () => { + cleanup.users.push(johnId, janeId); + cleanup.calls.push(`livestream:${callId}`); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a livestream call', async () => { + // #region snippet docs="video/api/03-streaming/01-overview.md" heading="Create a new livestream call using the API" tab="Node.js" index=1 + // COPY: johnId="john" + const call = client.video.call('livestream', callId); + const response = await call.getOrCreate({ + data: { + created_by_id: johnId, + // You can add multiple hosts if you want to + members: [{ user_id: johnId, role: 'host' }], + }, + }); + // #endregion snippet + + liveCall = call; + expect(response.created).toBe(true); + expect(response.call.cid).toBe(`livestream:${callId}`); + expect(response.call.created_by.id).toBe(johnId); + // Livestreams are created in backstage mode + expect(response.call.backstage).toBe(true); + expect(response.members.map((m) => [m.user_id, m.role])).toEqual([ + [johnId, 'host'], + ]); + }); + + it('sets the call live', async () => { + const call = liveCall; + const goLive = vi.spyOn(client.video, 'goLive'); + + // #region snippet docs="video/api/03-streaming/01-overview.md" heading="Set the call live" tab="Node.js" index=1 + await call.goLive(); + + // Optionally start processes that are set to `available` + await call.goLive({ + // Optionally start HLS broadcast + start_hls: true, + // Optionally start recording the call + start_recording: true, + // Optionally start displaying closed captions for call participants + start_closed_caption: true, + // Optionally start saving the call transcription to a file + start_transcription: true, + // Optionally select storage for the recording file (if none is specified, the default storage will be used) + recording_storage_name: '', + // Optionally select storage for the transcription file (if none is specified, the default storage will be used) + transcription_storage_name: '', + }); + // #endregion snippet + + const [first, second] = await Promise.all( + goLive.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(first.call.backstage).toBe(false); + expect(second.call.backstage).toBe(false); + expect(goLive.mock.calls[1][0]).toMatchObject({ + start_hls: true, + start_recording: true, + start_closed_caption: true, + start_transcription: true, + }); + const { call: live } = await call.get(); + expect(live.backstage).toBe(false); + }); + + it('logs the RTMP URL and stream key', async () => { + const call = liveCall; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="video/api/03-streaming/01-overview.md" heading="Log the URL & Stream Key" tab="Node.js" index=1 + // COPY: janeId="jane" + const resp = await call.get(); + + // userId of existing user + const userId = janeId; + await client.upsertUsers([{ id: userId }]); + const token = client.generateUserToken({ user_id: userId }); + const rtmpURL = resp.call.ingress.rtmp.address; + const streamKey = token; + + console.log(rtmpURL, streamKey); + // #endregion snippet + + expect(rtmpURL).toMatch(/^rtmps:\/\//); + expect(rtmpURL).toContain(`livestream.${callId}`); + expect(jwtPayload(streamKey).user_id).toBe(janeId); + expect(log.mock.calls[0]).toEqual([rtmpURL, streamKey]); + const { users } = await client.queryUsers({ + payload: { filter_conditions: { id: janeId } }, + }); + expect(users.map((u) => u.id)).toEqual([janeId]); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/03-streaming/02-backstage.test.ts b/__tests__/docs-snippets/pages/video/api/03-streaming/02-backstage.test.ts new file mode 100644 index 0000000..8141f4a --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/03-streaming/02-backstage.test.ts @@ -0,0 +1,214 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { OwnCapability } from '../../../../../../index'; +import { createCallType } from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'video/api/03-streaming/02-backstage.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + let callTypeName: string; + + /** A `livestream` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createLivestream = async () => { + const call = client.video.call('livestream', uniqueId('livestream')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ data: { created_by_id: johnId } }); + return call; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + callTypeName = await createCallType(client, cleanup); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a call in backstage mode with a join ahead time', async () => { + const outgoingCallId = uniqueId('test-outgoing-call'); + cleanup.calls.push(`livestream:${outgoingCallId}`); + const getOrCreate = vi.spyOn(client.video, 'getOrCreateCall'); + + // #region snippet docs="video/api/03-streaming/02-backstage.md" heading="Configuration" tab="Node.js" index=1 + // COPY: outgoingCallId="test-outgoing-call", johnId="john" + const startsAt = new Date(Date.now() + 30 * 60 * 1000); + await client.video.call('livestream', outgoingCallId).getOrCreate({ + data: { + starts_at: startsAt, + created_by_id: johnId, + settings_override: { + backstage: { + enabled: true, + join_ahead_time_seconds: 300, + }, + }, + }, + }); + // #endregion snippet + + const response = await (getOrCreate.mock.results[0].value as ReturnType< + typeof client.video.getOrCreateCall + >); + expect(response.created).toBe(true); + expect(response.call.backstage).toBe(true); + expect(response.call.starts_at?.getTime()).toBe(startsAt.getTime()); + expect(response.call.join_ahead_time_seconds).toBe(300); + expect(response.call.settings.backstage).toMatchObject({ + enabled: true, + join_ahead_time_seconds: 300, + }); + }); + + it('changes the backstage settings on the call and on the call type', async () => { + const call = await createLivestream(); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/03-streaming/02-backstage.md" heading="Configuration" tab="Node.js" index=2 + // COPY: callTypeName="" + // call level + await call.update({ + settings_override: { + backstage: { + enabled: true, + join_ahead_time_seconds: 300, + }, + }, + }); + + // or call type level + await client.video.updateCallType({ + name: callTypeName, + settings: { + backstage: { + enabled: true, + join_ahead_time_seconds: 300, + }, + }, + }); + // #endregion snippet + + const { call: updated } = await call.get(); + expect(updated.settings.backstage).toMatchObject({ + enabled: true, + join_ahead_time_seconds: 300, + }); + const callType = await (updateCallType.mock.results[0].value as ReturnType< + typeof client.video.updateCallType + >); + expect(callType.settings.backstage).toMatchObject({ + enabled: true, + join_ahead_time_seconds: 300, + }); + }); + + it('grants join-backstage to hosts', async () => { + const before = await client.video.getCallType({ name: callTypeName }); + expect(before.grants.host ?? []).not.toContain( + OwnCapability.JOIN_BACKSTAGE, + ); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/03-streaming/02-backstage.md" heading="Backstage Permissions" tab="Node.js" index=1 + // COPY: callTypeName="" + await client.video.updateCallType({ + name: callTypeName, + grants: { + host: [OwnCapability.JOIN_BACKSTAGE], + }, + }); + // #endregion snippet + + const response = await (updateCallType.mock.results[0].value as ReturnType< + typeof client.video.updateCallType + >); + expect(response.grants.host).toContain(OwnCapability.JOIN_BACKSTAGE); + }); + + it('goes live', async () => { + const call = await createLivestream(); + const goLive = vi.spyOn(client.video, 'goLive'); + + // #region snippet docs="video/api/03-streaming/02-backstage.md" heading="Go Live" tab="Node.js" index=1 + await call.goLive(); + + // Optionally start processes that are set to `available` + await call.goLive({ + // Optionally start HLS broadcast + start_hls: true, + // Optionally start recording the call + start_recording: true, + // Optionally start displaying closed captions for call participants + start_closed_caption: true, + // Optionally start saving the call transcription to a file + start_transcription: true, + // Optionally select storage for the recording file (if none is specified, the default storage will be used) + recording_storage_name: '', + // Optionally select storage for the transcription file (if none is specified, the default storage will be used) + transcription_storage_name: '', + }); + // #endregion snippet + + const [first, second] = await Promise.all( + goLive.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(first.call.backstage).toBe(false); + expect(second.call.backstage).toBe(false); + expect(goLive.mock.calls[1][0]).toMatchObject({ + start_hls: true, + start_recording: true, + start_closed_caption: true, + start_transcription: true, + }); + }); + + it('stops live', async () => { + const call = await createLivestream(); + await call.goLive(); + expect((await call.get()).call.backstage).toBe(false); + const stopLive = vi.spyOn(client.video, 'stopLive'); + + // #region snippet docs="video/api/03-streaming/02-backstage.md" heading="Stop Live" tab="Node.js" index=1 + await call.stopLive(); + + await call.stopLive({ + // Optionally prevent stoping HLS broadcast + continue_hls: true, + // Optionally prevent stoping recording + continue_recording: true, + // Optionally prevent stoping closed captions + continue_closed_caption: true, + // Optionally prevent stoping call transcription + continue_transcription: true, + }); + // #endregion snippet + + const [first, second] = await Promise.all( + stopLive.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(first.call.backstage).toBe(true); + expect(second.call.backstage).toBe(true); + expect((await call.get()).call.backstage).toBe(true); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/03-streaming/04-rtmp.test.ts b/__tests__/docs-snippets/pages/video/api/03-streaming/04-rtmp.test.ts new file mode 100644 index 0000000..28176df --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/03-streaming/04-rtmp.test.ts @@ -0,0 +1,69 @@ +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 = 'video/api/03-streaming/04-rtmp.md'; + +/** Reads the JSON payload of a JWT. */ +const jwtPayload = (token: string) => + JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString('utf8'), + ) as Record; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const hostId = uniqueId('host'); + const janeId = uniqueId('jane'); + + beforeAll(async () => { + cleanup.users.push(hostId, janeId); + await client.upsertUsers([{ id: hostId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('gets the RTMP URL and stream key', async () => { + const call = client.video.call('livestream', uniqueId('livestream')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ data: { created_by_id: hostId } }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="video/api/03-streaming/04-rtmp.md" heading="RTMP publishing" tab="Node.js" index=1 + // COPY: janeId="jane" + const resp = await call.get(); + + // userId of existing user + const userId = janeId; + await client.upsertUsers([{ id: userId }]); + const token = client.generateUserToken({ user_id: userId }); + const rtmpURL = resp.call.ingress.rtmp.address; + const streamKey = token; + + console.log(rtmpURL, streamKey); + // #endregion snippet + + expect(rtmpURL).toMatch(/^rtmps:\/\//); + expect(rtmpURL).toContain(`livestream.${call.id}`); + expect(jwtPayload(streamKey).user_id).toBe(janeId); + expect(log.mock.calls[0]).toEqual([rtmpURL, streamKey]); + const { users } = await client.queryUsers({ + payload: { filter_conditions: { id: janeId } }, + }); + expect(users.map((u) => u.id)).toEqual([janeId]); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/03-streaming/06-srt.test.ts b/__tests__/docs-snippets/pages/video/api/03-streaming/06-srt.test.ts new file mode 100644 index 0000000..d503a00 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/03-streaming/06-srt.test.ts @@ -0,0 +1,86 @@ +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 = 'video/api/03-streaming/06-srt.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const hostUserId = uniqueId('host-user'); + const johnId = uniqueId('john'); + const myLivestreamId = uniqueId('my-livestream'); + + beforeAll(async () => { + cleanup.users.push(hostUserId, johnId); + cleanup.calls.push(`livestream:${myLivestreamId}`); + await client.upsertUsers([{ id: hostUserId }, { id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a call and generates SRT credentials', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const getOrCreate = vi.spyOn(client.video, 'getOrCreateCall'); + + // #region snippet docs="video/api/03-streaming/06-srt.md" heading="1) Create a call and generate SRT credentials" tab="Node.js" index=1 + // COPY: hostUserId="host-user-id", myLivestreamId="my-livestream", johnId="john" + const userID = hostUserId; + const callID = myLivestreamId; + + const call = client.video.call('livestream', callID); + await call.getOrCreate({ + data: { + created_by_id: userID, + members: [{ user_id: johnId, role: 'host' }], + }, + }); + const credentials = call.createSRTCredentials(userID); + + console.log(credentials.address); + // #endregion snippet + + const response = await (getOrCreate.mock.results[0].value as ReturnType< + typeof client.video.getOrCreateCall + >); + expect(response.created).toBe(true); + expect(response.call.created_by.id).toBe(hostUserId); + const template = response.call.ingress.srt.address; + expect(template).toContain('{passphrase}'); + expect(template).toContain('{token}'); + // The address embeds a user token for the host and its signature as the passphrase + const url = new URL(credentials.address); + const token = String(url.searchParams.get('streamid')).split('/').pop(); + const [, payload, signature] = String(token).split('.'); + expect(url.searchParams.get('passphrase')).toBe(signature); + expect( + ( + JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { + user_id: string; + } + ).user_id, + ).toBe(hostUserId); + expect(credentials.address).toBe( + template + .replace('{passphrase}', signature) + .replace('{token}', String(token)), + ); + expect(credentials.address).toMatch(/^srt:\/\//); + expect(credentials.address).toContain(`livestream.${myLivestreamId}`); + expect(log.mock.calls[0]).toEqual([credentials.address]); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/03-streaming/08-hls.test.ts b/__tests__/docs-snippets/pages/video/api/03-streaming/08-hls.test.ts new file mode 100644 index 0000000..71c45b5 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/03-streaming/08-hls.test.ts @@ -0,0 +1,186 @@ +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 { joinCallAsUser } from '../../../../helpers/video'; +import { retry } from '../../../../helpers/wait'; +import type { CallResponse } from '../../../../../../index'; + +const DOCS = 'video/api/03-streaming/08-hls.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** A `livestream` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createLivestream = async () => { + const call = client.video.call('livestream', uniqueId('livestream')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ data: { created_by_id: johnId } }); + return call; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts and stops an HLS broadcast', async () => { + const call = await createLivestream(); + // HLS needs a call session: john (the creator, who may join the backstage) + // joins over REST (see AGENTS.md). + await joinCallAsUser(call, johnId); + cleanup.add(() => call.stopHLSBroadcasting().catch(() => undefined)); + // Read the egress state right after the start, before the stop. + const original = client.video.startHLSBroadcasting.bind(client.video); + let egressAfterStart: CallResponse['egress'] | undefined; + const startHLS = vi + .spyOn(client.video, 'startHLSBroadcasting') + .mockImplementation(async (request) => { + const response = await original(request); + egressAfterStart = (await call.get()).call.egress; + return response; + }); + const stopHLS = vi.spyOn(client.video, 'stopHLSBroadcasting'); + + // #region snippet docs="video/api/03-streaming/08-hls.md" heading="Start and stop HLS broadcast" tab="Node.js" index=1 + await call.startHLSBroadcasting(); + + // to end broadcasting + await call.stopHLSBroadcasting(); + // #endregion snippet + + const started = await (startHLS.mock.results[0].value as ReturnType< + typeof client.video.startHLSBroadcasting + >); + expect(started.playlist_url).toMatch(/playlist\.m3u8/); + expect(egressAfterStart?.broadcasting).toBe(true); + expect(egressAfterStart?.hls?.playlist_url).toBe(started.playlist_url); + expect(stopHLS).toHaveBeenCalledTimes(1); + const egress = await retry(async () => { + const { call: data } = await call.get(); + if (data.egress.broadcasting) throw new Error('still broadcasting'); + return data.egress; + }); + expect(egress.broadcasting).toBe(false); + }); + + it('goes live, optionally starting processes', async () => { + const call = await createLivestream(); + const goLive = vi.spyOn(client.video, 'goLive'); + + // #region snippet docs="video/api/03-streaming/08-hls.md" heading="Start and stop HLS broadcast" tab="Node.js" index=2 + await call.goLive(); + + // Optionally start processes that are set to `available` + await call.goLive({ + // Optionally start HLS broadcast + start_hls: true, + // Optionally start recording the call + start_recording: true, + // Optionally start displaying closed captions for call participants + start_closed_caption: true, + // Optionally start saving the call transcription to a file + start_transcription: true, + // Optionally select storage for the recording file (if none is specified, the default storage will be used) + recording_storage_name: '', + // Optionally select storage for the transcription file (if none is specified, the default storage will be used) + transcription_storage_name: '', + }); + // #endregion snippet + + // With no participants the processes don't start (see AGENTS.md): assert backstage only. + const [first, second] = await Promise.all( + goLive.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + expect(first.call.backstage).toBe(false); + expect(second.call.backstage).toBe(false); + expect(goLive.mock.calls[1][0]).toMatchObject({ + start_hls: true, + start_recording: true, + start_closed_caption: true, + start_transcription: true, + }); + }); + + it('reads the broadcast state', async () => { + const call = await createLivestream(); + + // #region snippet docs="video/api/03-streaming/08-hls.md" heading="Broadcast state" tab="Node.js" index=1 + const resp = await call.get(); + const isBroadcasting = resp.call.egress.broadcasting; + // #endregion snippet + + expect(resp.call.cid).toBe(call.cid); + expect(isBroadcasting).toBe(false); + }); + + it('reads the HLS playlist URL', async () => { + const call = await createLivestream(); + + // #region snippet docs="video/api/03-streaming/08-hls.md" heading="Consuming HLS broadcast" tab="Node.js" index=1 + const resp = await call.get(); + const URL = resp.call.egress.hls?.playlist_url; + // #endregion snippet + + expect(resp.call.cid).toBe(call.cid); + // No broadcast is running, so there is no playlist yet. + expect(URL).toBeUndefined(); + }); + + it('creates a call with HLS quality tracks', async () => { + const myCallId = uniqueId('my-call'); + cleanup.calls.push(`default:${myCallId}`); + const getOrCreate = vi.spyOn(client.video, 'getOrCreateCall'); + + // #region snippet docs="video/api/03-streaming/08-hls.md" heading="Multitracking" tab="Node.js" index=1 + // COPY: myCallId="my-call", johnId="john" + const callType = 'default'; + const callId = myCallId; + const call = client.video.call(callType, callId); + + // optionally provide additional data + await call.getOrCreate({ + data: { + created_by_id: johnId, + settings_override: { + broadcasting: { + enabled: true, + hls: { + quality_tracks: ['720p', '480p', '360p'], + }, + }, + }, + }, + }); + // #endregion snippet + + const response = await (getOrCreate.mock.results[0].value as ReturnType< + typeof client.video.getOrCreateCall + >); + expect(response.created).toBe(true); + expect(response.call.cid).toBe(call.cid); + expect(response.call.settings.broadcasting).toMatchObject({ + enabled: true, + hls: { quality_tracks: ['720p', '480p', '360p'] }, + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/03-streaming/09-rtmp_broadcast.test.ts b/__tests__/docs-snippets/pages/video/api/03-streaming/09-rtmp_broadcast.test.ts new file mode 100644 index 0000000..6d55eb5 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/03-streaming/09-rtmp_broadcast.test.ts @@ -0,0 +1,177 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createCallType } from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { joinCallAsUser } from '../../../../helpers/video'; + +const DOCS = 'video/api/03-streaming/09-rtmp_broadcast.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** A `livestream` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createLivestream = async () => { + const call = client.video.call('livestream', uniqueId('livestream')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ data: { created_by_id: johnId } }); + return call; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets the RTMP quality and layout on a call type', async () => { + // The docs update the built-in `livestream` type: run it on a test call type. + const callTypeName = await createCallType(client, cleanup); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/03-streaming/09-rtmp_broadcast.md" heading="Configuring the layout for your rtmp broadcast" tab="Node.js" index=1 + // COPY: callTypeName="livestream" + await client.video.updateCallType({ + name: callTypeName, + settings: { + broadcasting: { + enabled: true, + rtmp: { + enabled: true, + quality: '1080p', + layout: { + name: 'spotlight', + }, + }, + }, + }, + }); + // #endregion snippet + + const response = await (updateCallType.mock.results[0].value as ReturnType< + typeof client.video.updateCallType + >); + expect(response.name).toBe(callTypeName); + expect(response.settings.broadcasting.enabled).toBe(true); + expect(response.settings.broadcasting.rtmp).toMatchObject({ + enabled: true, + quality: '1080p', + layout: { name: 'spotlight' }, + }); + }); + + /** + * A livestream with a call session (john, the creator, may join the backstage) + * and a safety stop of every RTMP broadcast in cleanup. + */ + const createJoinedLivestream = async () => { + const call = await createLivestream(); + await joinCallAsUser(call, johnId); + cleanup.add(() => call.stopAllRTMPBroadcasts().catch(() => undefined)); + return call; + }; + + /** The broadcast the docs start (the RTMP target is a placeholder). */ + const youtubeBroadcast = { + name: 'youtube_channel', + stream_url: 'rtmps://x.rtmps.youtube.com/live2', + stream_key: 'your_stream_key', + }; + + // The start is accepted (201), but the placeholder target never connects: + // `egress.rtmps` stays empty (2026-10-16). + it('starts an RTMP broadcast', async () => { + const call = await createJoinedLivestream(); + const startRTMP = vi.spyOn(client.video, 'startRTMPBroadcasts'); + + // #region snippet docs="video/api/03-streaming/09-rtmp_broadcast.md" heading="Starting an RTMP broadcast" tab="Node.js" index=1 + await call.startRTMPBroadcasts({ + broadcasts: [ + { + name: 'youtube_channel', + stream_url: 'rtmps://x.rtmps.youtube.com/live2', + stream_key: 'your_stream_key', + }, + ], + }); + // #endregion snippet + + expect(startRTMP).toHaveBeenCalledWith({ + type: call.type, + id: call.id, + broadcasts: [youtubeBroadcast], + }); + const response = await (startRTMP.mock.results[0].value as ReturnType< + typeof client.video.startRTMPBroadcasts + >); + expect(response.metadata.responseCode).toBe(201); + }); + + it('stops an RTMP broadcast by name', async () => { + const call = await createJoinedLivestream(); + await call.startRTMPBroadcasts({ broadcasts: [youtubeBroadcast] }); + const stopRTMP = vi.spyOn(client.video, 'stopRTMPBroadcast'); + + // #region snippet docs="video/api/03-streaming/09-rtmp_broadcast.md" heading="Stopping an RTMP broadcast by name" tab="Node.js" index=1 + await call.stopRTMPBroadcast({ name: 'youtube_channel' }); + // #endregion snippet + + expect(stopRTMP).toHaveBeenCalledWith({ + type: call.type, + id: call.id, + name: 'youtube_channel', + }); + const { call: data } = await call.get(); + expect(data.egress.rtmps).toEqual([]); + }); + + it('stops all RTMP broadcasts of a call', async () => { + const call = await createJoinedLivestream(); + await call.startRTMPBroadcasts({ broadcasts: [youtubeBroadcast] }); + const stopAll = vi.spyOn(client.video, 'stopAllRTMPBroadcasts'); + + // #region snippet docs="video/api/03-streaming/09-rtmp_broadcast.md" heading="Stopping all RTMP broadcasts for a call" tab="Node.js" index=1 + await call.stopAllRTMPBroadcasts(); + // #endregion snippet + + expect(stopAll).toHaveBeenCalledWith({ type: call.type, id: call.id }); + const { call: data } = await call.get(); + expect(data.egress.rtmps).toEqual([]); + }); + + it('reads the RTMP broadcast state', async () => { + const call = await createLivestream(); + + // #region snippet docs="video/api/03-streaming/09-rtmp_broadcast.md" heading="RTMP broadcast state" tab="Node.js" index=1 + const resp = await call.get(); + + // If HLS or any RTMP broadcast active + const isBroadcasting = resp.call.egress.broadcasting; + + // Only check for RTMP broadcasts + const rtmpBroadcasts = resp.call.egress.rtmps; + const isRtmpBroadcasting = rtmpBroadcasts.length > 0; + // #endregion snippet + + expect(resp.call.cid).toBe(call.cid); + expect(isBroadcasting).toBe(false); + expect(rtmpBroadcasts).toEqual([]); + expect(isRtmpBroadcasting).toBe(false); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/04-quality/03-stats.test.ts b/__tests__/docs-snippets/pages/video/api/04-quality/03-stats.test.ts new file mode 100644 index 0000000..d82e5b5 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/04-quality/03-stats.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getServerClient } from '../../../../helpers/clients'; +import { retryOnRateLimit } from '../../../../helpers/wait'; + +const DOCS = 'video/api/04-quality/03-stats.md'; + +describe(DOCS, () => { + const client = getServerClient(); + + afterEach(() => vi.restoreAllMocks()); + + it('Active calls status endpoint', async () => { + // `getActiveCallsStatus` is limited to 6 requests per minute: retry on 429. + const original = client.video.getActiveCallsStatus.bind(client.video); + vi.spyOn(client.video, 'getActiveCallsStatus').mockImplementation(() => + retryOnRateLimit(original), + ); + + // #region snippet docs="video/api/04-quality/03-stats.md" heading="Active calls status endpoint" tab="Node.js" + const response = await client.video.getActiveCallsStatus(); + // #endregion snippet + + // The status covers the last minute. + expect(response.start_time).toBeInstanceOf(Date); + expect(response.end_time).toBeInstanceOf(Date); + expect( + response.end_time.getTime() - response.start_time.getTime(), + ).toBeGreaterThan(0); + // The test app usually has no running calls, so the numbers can be 0. + expect(typeof response.summary?.active_calls).toBe('number'); + expect(typeof response.summary?.active_publishers).toBe('number'); + expect(typeof response.summary?.active_subscribers).toBe('number'); + expect(typeof response.summary?.participants).toBe('number'); + expect(response.metrics).toHaveProperty('join_call_api'); + expect(response.metrics).toHaveProperty('publishers'); + expect(response.metrics).toHaveProperty('subscribers'); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/06-moderation/01-overview.test.ts b/__tests__/docs-snippets/pages/video/api/06-moderation/01-overview.test.ts new file mode 100644 index 0000000..bf0d832 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/06-moderation/01-overview.test.ts @@ -0,0 +1,383 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { OwnCapability } from '../../../../../../index'; +import type { + BlockUserResponse, + ExportUsersResponse, + GetBlockedUsersResponse, + ModerationBanResponse, + MuteUsersResponse, + UpdateUserPermissionsResponse, +} from '../../../../../../index'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { joinCallAsUser } from '../../../../helpers/video'; +import { waitForTask } from '../../../../helpers/wait'; + +const DOCS = 'video/api/06-moderation/01-overview.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** A `default` call with a `uniqueId` id, created by john, with the given members. */ + const createCall = async (...memberIds: string[]) => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ + data: { + created_by_id: johnId, + members: [johnId, ...memberIds].map((user_id) => ({ user_id })), + }, + }); + return call; + }; + + /** Creates `uniqueId` users and registers them for cleanup. */ + const createUsers = async (...names: string[]) => { + const ids = names.map((name) => uniqueId(name)); + cleanup.users.push(...ids); + await client.upsertUsers(ids.map((id) => ({ id }))); + return ids; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('blocks, kicks and unblocks a member', async () => { + const [saraId] = await createUsers('sara'); + const call = await createCall(saraId); + await joinCallAsUser(call, saraId); + // Read the blocked list right after the block, before the unblock. + const original = client.video.blockUser.bind(client.video); + const blockUser = vi.spyOn(client.video, 'blockUser'); + const kickUser = vi.spyOn(client.video, 'kickUser'); + const unblockUser = vi.spyOn(client.video, 'unblockUser'); + let blockedAfterBlock: string[] = []; + blockUser.mockImplementation(async (request) => { + const response = await original(request); + blockedAfterBlock = (await call.get()).call.blocked_user_ids; + return response; + }); + + // #region snippet docs="video/api/06-moderation/01-overview.md" heading="Kicking & Blocking a member from a call" tab="Node.js" index=1 + // COPY: saraId="sara" + // Block user + await call.blockUser({ user_id: saraId }); + + // kick + await call.kickUser({ user_id: saraId }); + + // Unblock user + await call.unblockUser({ user_id: saraId }); + // #endregion snippet + + const [blocked] = await Promise.all( + blockUser.mock.results.map((r) => r.value as Promise), + ); + expect(blocked.duration).toBeDefined(); + expect(blockedAfterBlock).toEqual([saraId]); + expect(kickUser).toHaveBeenCalledWith( + expect.objectContaining({ user_id: saraId }), + ); + await Promise.all( + kickUser.mock.results.map((r) => r.value as Promise), + ); + expect(unblockUser).toHaveBeenCalledTimes(1); + expect((await call.get()).call.blocked_user_ids).toEqual([]); + }); + + it('enables screen sharing with access requests', async () => { + const call = await createCall(); + + // #region snippet docs="video/api/06-moderation/01-overview.md" heading="Call permissions" tab="Node.js" index=1 + await call.update({ + settings_override: { + screensharing: { enabled: true, access_request_enabled: true }, + }, + }); + // #endregion snippet + + const { call: updated } = await call.get(); + expect(updated.settings.screensharing).toMatchObject({ + enabled: true, + access_request_enabled: true, + }); + }); + + it('mutes everyone', async () => { + const [saraId] = await createUsers('sara'); + const call = await createCall(saraId); + await joinCallAsUser(call, saraId); + const muteUsers = vi.spyOn(client.video, 'muteUsers'); + + // #region snippet docs="video/api/06-moderation/01-overview.md" heading="Muting everyone" tab="Node.js" index=1 + // COPY: johnId="john" + // You can specify which kind of stream(s) to mute + await call.muteUsers({ + mute_all_users: true, + audio: true, + muted_by_id: johnId, + }); + // #endregion snippet + + const [muted] = await Promise.all( + muteUsers.mock.results.map((r) => r.value as Promise), + ); + expect(muted.duration).toBeDefined(); + expect(muteUsers).toHaveBeenCalledWith( + expect.objectContaining({ mute_all_users: true, audio: true }), + ); + }); + + it("mutes one participant's tracks", async () => { + const [saraId] = await createUsers('sara'); + const call = await createCall(saraId); + await joinCallAsUser(call, saraId); + const muteUsers = vi.spyOn(client.video, 'muteUsers'); + + // #region snippet docs="video/api/06-moderation/01-overview.md" heading="Muting one participant's video or audio (or both)" tab="Node.js" index=1 + // COPY: saraId="sara", johnId="john" + await call.muteUsers({ + user_ids: [saraId], + audio: true, + video: true, + screenshare: true, + screenshare_audio: true, + muted_by_id: johnId, + }); + // #endregion snippet + + const [muted] = await Promise.all( + muteUsers.mock.results.map((r) => r.value as Promise), + ); + expect(muted.duration).toBeDefined(); + expect(muteUsers).toHaveBeenCalledWith( + expect.objectContaining({ user_ids: [saraId] }), + ); + }); + + it('grants and revokes a permission', async () => { + const [saraId] = await createUsers('sara'); + const call = await createCall(saraId); + const updatePermissions = vi.spyOn(client.video, 'updateUserPermissions'); + + // #region snippet docs="video/api/06-moderation/01-overview.md" heading="Granting and revoking permissions" tab="Node.js" index=1 + // COPY: saraId="sara" + await call.updateUserPermissions({ + user_id: saraId, + grant_permissions: [OwnCapability.SEND_AUDIO], + }); + // #endregion snippet + + // #region snippet docs="video/api/06-moderation/01-overview.md" heading="Granting and revoking permissions" tab="Node.js" index=2 + // COPY: saraId="sara" + await call.updateUserPermissions({ + user_id: saraId, + revoke_permissions: [OwnCapability.SEND_AUDIO], + }); + // #endregion snippet + + const responses = await Promise.all( + updatePermissions.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(responses).toHaveLength(2); + expect(updatePermissions.mock.calls.map(([request]) => request)).toEqual([ + expect.objectContaining({ grant_permissions: ['send-audio'] }), + expect.objectContaining({ revoke_permissions: ['send-audio'] }), + ]); + }); + + it('bans and unbans a user', async () => { + const [badUserId, moderatorId] = await createUsers('bad-user', 'moderator'); + const bannedUserId = badUserId; + const banReason = 'spamming the call'; + cleanup.add(() => client.moderation.unban({ target_user_id: badUserId })); + // Record the user's ban after each ban call. + const originalBan = client.moderation.ban.bind(client.moderation); + const ban = vi.spyOn(client.moderation, 'ban'); + const unban = vi.spyOn(client.moderation, 'unban'); + const bansAfterEachCall: Array<{ reason?: string; expires?: Date }> = []; + ban.mockImplementation(async (request) => { + const response = await originalBan(request); + const { bans } = await client.queryBannedUsers({ + payload: { filter_conditions: { user_id: badUserId } }, + }); + bansAfterEachCall.push({ + reason: bans[0]?.reason, + expires: bans[0]?.expires, + }); + return response; + }); + + // #region snippet docs="video/api/06-moderation/01-overview.md" heading="Banning users" tab="Node.js" index=1 + // COPY: badUserId="", moderatorId="", banReason="", bannedUserId="" + await client.moderation.ban({ + target_user_id: badUserId, + banned_by_id: moderatorId, + reason: banReason, + }); + + // remove the ban for a user + await client.moderation.unban({ + target_user_id: bannedUserId, + }); + + // ban a user for 30 minutes + await client.moderation.ban({ + target_user_id: badUserId, + banned_by_id: moderatorId, + timeout: 30, + }); + + // ban a user and all users sharing the same IP + await client.moderation.ban({ + target_user_id: badUserId, + banned_by_id: moderatorId, + reason: banReason, + ip_ban: true, + }); + // #endregion snippet + + await Promise.all( + ban.mock.results.map((r) => r.value as Promise), + ); + expect(ban).toHaveBeenCalledTimes(3); + expect(unban).toHaveBeenCalledTimes(1); + expect(bansAfterEachCall).toHaveLength(3); + expect(bansAfterEachCall[0]).toMatchObject({ reason: banReason }); + expect(bansAfterEachCall[0].expires).toBeUndefined(); + expect(bansAfterEachCall[1].expires).toBeInstanceOf(Date); + const { bans } = await client.queryBannedUsers({ + payload: { filter_conditions: { user_id: badUserId } }, + }); + expect(bans).toHaveLength(1); + expect(bans[0].reason).toBe(banReason); + }); + + it('deactivates and reactivates users', async () => { + const [userId, bulkUser1, bulkUser2] = await createUsers( + 'deactivate', + 'bulk1', + 'bulk2', + ); + const reactivateUsers = vi.spyOn(client, 'reactivateUsers'); + + // #region snippet docs="video/api/06-moderation/01-overview.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 findUser = async (id: string) => + ( + await client.queryUsers({ + payload: { + filter_conditions: { id }, + include_deactivated_users: true, + }, + }) + ).users[0]; + const [reactivated] = await Promise.all( + reactivateUsers.mock.results.map( + (r) => r.value as ReturnType, + ), + ); + 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('monitors an async task', async () => { + const [exportUserId] = await createUsers('export'); + const exportUsers = vi.spyOn(client, 'exportUsers'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // #region snippet docs="video/api/06-moderation/01-overview.md" heading="Deactivating users" tab="Node.js" index=2 + // COPY: exportUserId="" + // Example of monitoring the status of an async task + // The logic is same for all async tasks + const response = await client.exportUsers({ + user_ids: [exportUserId, exportUserId], + }); + + // you need to poll this endpoint + const taskResponse = await client.getTask({ id: response.task_id }); + + console.log(taskResponse.status === 'completed'); + // #endregion snippet + + const [exported] = await Promise.all( + exportUsers.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(exported.task_id).toBe(response.task_id); + expect(taskResponse.task_id).toBe(response.task_id); + expect(log.mock.calls[0]).toEqual([taskResponse.status === 'completed']); + await waitForTask(client, response.task_id); + }); + + it('blocks and unblocks a user', async () => { + const [bobId, aliceId] = await createUsers('bob', 'alice'); + const getBlockedUsers = vi.spyOn(client, 'getBlockedUsers'); + + // #region snippet docs="video/api/06-moderation/01-overview.md" heading="User blocking" tab="Node.js" index=1 + // COPY: bobId="bob", aliceId="alice" + await client.blockUsers({ + blocked_user_id: bobId, + user_id: aliceId, + }); + + await client.getBlockedUsers({ user_id: aliceId }); + + await client.unblockUsers({ + blocked_user_id: bobId, + user_id: aliceId, + }); + // #endregion snippet + + const [blocked] = await Promise.all( + getBlockedUsers.mock.results.map( + (r) => r.value as Promise, + ), + ); + expect(blocked.blocks.map((b) => b.blocked_user_id)).toEqual([bobId]); + const after = await client.getBlockedUsers({ user_id: aliceId }); + expect(after.blocks).toEqual([]); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/07-recording/02-composite.test.ts b/__tests__/docs-snippets/pages/video/api/07-recording/02-composite.test.ts new file mode 100644 index 0000000..fb426a4 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/07-recording/02-composite.test.ts @@ -0,0 +1,357 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createCallType } from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { joinCallAsUser } from '../../../../helpers/video'; + +const DOCS = 'video/api/07-recording/02-composite.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** A `default` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createCall = async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ data: { created_by_id: johnId } }); + return call; + }; + + /** The awaited results of every call to a spied update method. */ + const updateResults = (spy: { mock: { results: { value: unknown }[] } }) => + Promise.all(spy.mock.results.map((r) => r.value as Promise)); + + type UpdateCallResponse = Awaited>; + type UpdateCallTypeResponse = Awaited< + ReturnType + >; + + /** + * A call john is a member of and has joined over REST: recording needs a call + * session (see AGENTS.md). Stops the composite recording in cleanup in case the + * test fails before its stop. + */ + const createJoinedCall = async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }); + await joinCallAsUser(call, johnId); + cleanup.add(() => + call + .stopRecording({ recording_type: 'composite' }) + .catch(() => undefined), + ); + return call; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts and stops a composite recording and lists the recordings', async () => { + const call = await createJoinedCall(); + const stopRecording = vi.spyOn(client.video, 'stopRecording'); + + // #region snippet docs="video/api/07-recording/02-composite.md" heading="Quickstart" tab="Node.js" index=1 + // Start composite recording + // Triggers webhook event: call.recording_started + await call.startRecording({ recording_type: 'composite' }); + + // Stop composite recording + // Triggers webhook event: call.recording_stopped + // When processing completes: call.recording_ready (contains recording URL) + await call.stopRecording({ recording_type: 'composite' }); + + // List all recordings for this call + const response = await call.listRecordings(); + + // Download using the URL from response.recordings or the call.recording_ready webhook payload + // response.recordings[0].url + // #endregion snippet + + // Right after the start the composite recorder is still starting: `recording` + // turns true a few seconds later (2026-10-16). + expect(stopRecording).toHaveBeenCalledWith({ + type: call.type, + id: call.id, + recording_type: 'composite', + }); + // Stopped right after the start: nothing has been uploaded yet. + expect(response.recordings).toEqual([]); + }); + + it('enables, disables and configures recording', async () => { + const call = await createCall(); + // The docs update a call type: run it on a test call type. + const recordingCallType = await createCallType(client, cleanup); + const updateCall = vi.spyOn(client.video, 'updateCall'); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/07-recording/02-composite.md" heading="Enabling / Disabling call recording" tab="Node.js" index=1 + // COPY: recordingCallType="" + // Disable on call level + await call.update({ + settings_override: { + recording: { + mode: 'disabled', + }, + }, + }); + + // Disable on call type level + await client.video.updateCallType({ + name: recordingCallType, + settings: { + recording: { + mode: 'disabled', + }, + }, + }); + + // Automatically record calls + await client.video.updateCallType({ + name: recordingCallType, + settings: { + recording: { + mode: 'auto-on', + quality: '720p', + }, + }, + }); + + // Enable + await call.update({ + settings_override: { + recording: { + mode: 'available', + quality: '720p', + }, + }, + }); + + // Other settings + await call.update({ + settings_override: { + recording: { + mode: 'available', + audio_only: false, + quality: '1080p', + }, + }, + }); + // #endregion snippet + + const calls = await updateResults(updateCall); + expect(calls.map((r) => r.call.settings.recording.mode)).toEqual([ + 'disabled', + 'available', + 'available', + ]); + expect(calls[1].call.settings.recording.quality).toBe('720p'); + expect(calls[2].call.settings.recording).toMatchObject({ + audio_only: false, + quality: '1080p', + }); + const types = await updateResults(updateCallType); + expect(types.map((r) => r.name)).toEqual([ + recordingCallType, + recordingCallType, + ]); + expect(types[0].settings.recording.mode).toBe('disabled'); + expect(types[1].settings.recording).toMatchObject({ + mode: 'auto-on', + quality: '720p', + }); + }); + + it('enables audio only recording on a call', async () => { + const call = await createCall(); + const updateCall = vi.spyOn(client.video, 'updateCall'); + + // #region snippet docs="video/api/07-recording/02-composite.md" heading="Audio only recording" tab="Node.js" index=1 + // Enable + await call.update({ + settings_override: { + recording: { + mode: 'available', + audio_only: true, + }, + }, + }); + // #endregion snippet + + const [response] = await updateResults(updateCall); + expect(response.call.cid).toBe(call.cid); + expect(response.call.settings.recording).toMatchObject({ + mode: 'available', + audio_only: true, + }); + }); + + it('sets layout options on a call type', async () => { + const callTypeName = await createCallType(client, cleanup); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/07-recording/02-composite.md" heading="Layout options" tab="Node.js" index=1 + const layoutOptions = { + 'logo.image_url': + 'https://theme.zdassets.com/theme_assets/9442057/efc3820e436f9150bc8cf34267fff4df052a1f9c.png', + 'logo.horizontal_position': 'center', + 'title.text': 'Building Stream Video Q&A', + 'title.horizontal_position': 'center', + 'title.color': 'black', + 'participant_label.border_radius': '0px', + 'participant.border_radius': '0px', + 'layout.spotlight.participants_bar_position': 'top', + 'layout.background_color': '#f2f2f2', + 'participant.placeholder_background_color': '#1f1f1f', + 'layout.single-participant.padding_inline': '20%', + 'participant_label.background_color': 'transparent', + }; + + await client.video.updateCallType({ + name: callTypeName, + settings: { + recording: { + mode: 'available', + audio_only: false, + quality: '1080p', + layout: { + name: 'spotlight', + options: layoutOptions, + }, + }, + }, + }); + // #endregion snippet + + const [response] = + await updateResults(updateCallType); + expect(response.name).toBe(callTypeName); + expect(response.settings.recording).toMatchObject({ + mode: 'available', + audio_only: false, + quality: '1080p', + layout: { name: 'spotlight' }, + }); + expect(response.settings.recording.layout.options).toMatchObject( + layoutOptions, + ); + }); + + it('sets a portrait recording resolution on a call type', async () => { + const callTypeName = await createCallType(client, cleanup); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/07-recording/02-composite.md" heading="Recording resolution and portrait mode" tab="Node.js" index=1 + await client.video.updateCallType({ + name: callTypeName, + settings: { + recording: { + mode: 'available', + quality: 'portrait-1080x1920', + }, + }, + }); + // #endregion snippet + + const [response] = + await updateResults(updateCallType); + expect(response.name).toBe(callTypeName); + expect(response.settings.recording).toMatchObject({ + mode: 'available', + quality: 'portrait-1080x1920', + }); + }); + + it('sets an external CSS file for the recording layout', async () => { + const callTypeName = await createCallType(client, cleanup); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/07-recording/02-composite.md" heading="Custom recording styling using external CSS" tab="Node.js" index=1 + await client.video.updateCallType({ + name: callTypeName, + settings: { + recording: { + mode: 'available', + audio_only: false, + quality: '1080p', + layout: { + name: 'spotlight', + external_css_url: 'https://path/to/custom.css', + }, + }, + }, + }); + // #endregion snippet + + const [response] = + await updateResults(updateCallType); + expect(response.name).toBe(callTypeName); + expect(response.settings.recording).toMatchObject({ + mode: 'available', + audio_only: false, + quality: '1080p', + layout: { + name: 'spotlight', + external_css_url: 'https://path/to/custom.css', + }, + }); + }); + + it('records calls with a custom web application', async () => { + const callTypeName = await createCallType(client, cleanup); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/07-recording/02-composite.md" heading="Advanced - record calls using a custom web application" tab="Node.js" index=1 + await client.video.updateCallType({ + name: callTypeName, + settings: { + recording: { + mode: 'available', + audio_only: false, + quality: '1080p', + layout: { + name: 'custom', + external_app_url: 'https://path/to/layout/app', + }, + }, + }, + }); + // #endregion snippet + + const [response] = + await updateResults(updateCallType); + expect(response.name).toBe(callTypeName); + expect(response.settings.recording).toMatchObject({ + mode: 'available', + audio_only: false, + quality: '1080p', + layout: { + name: 'custom', + external_app_url: 'https://path/to/layout/app', + }, + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/07-recording/03-individual_track.test.ts b/__tests__/docs-snippets/pages/video/api/07-recording/03-individual_track.test.ts new file mode 100644 index 0000000..3a5d2ab --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/07-recording/03-individual_track.test.ts @@ -0,0 +1,168 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createCallType } from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { joinCallAsUser } from '../../../../helpers/video'; +import type { CallResponse } from '../../../../../../index'; + +const DOCS = 'video/api/07-recording/03-individual_track.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** A `default` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createCall = async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ data: { created_by_id: johnId } }); + return call; + }; + + /** + * A call john is a member of and has joined over REST: recording needs a call + * session (see AGENTS.md). Stops the individual recording in cleanup in case the + * test fails before its stop. + */ + const createJoinedCall = async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }); + await joinCallAsUser(call, johnId); + cleanup.add(() => + call + .stopRecording({ recording_type: 'individual' }) + .catch(() => undefined), + ); + return call; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts and stops an individual recording and lists the recordings', async () => { + const call = await createJoinedCall(); + // Read the egress state right after the start, before the stop. + const original = client.video.startRecording.bind(client.video); + let egressAfterStart: CallResponse['egress'] | undefined; + vi.spyOn(client.video, 'startRecording').mockImplementation( + async (request) => { + const response = await original(request); + egressAfterStart = (await call.get()).call.egress; + return response; + }, + ); + const stopRecording = vi.spyOn(client.video, 'stopRecording'); + + // #region snippet docs="video/api/07-recording/03-individual_track.md" heading="Quickstart" tab="Node.js" index=1 + // Start individual recording + // Triggers webhook event: call.recording_started + await call.startRecording({ recording_type: 'individual' }); + + // Stop individual recording + // Triggers webhook event: call.recording_stopped + // When processing completes: call.recording_ready (one event per file, contains recording URL) + await call.stopRecording({ recording_type: 'individual' }); + + // List all recordings for this call + const response = await call.listRecordings(); + + // Download using the URL from response.recordings or the call.recording_ready webhook payload + // response.recordings[0].url + // #endregion snippet + + expect(['starting', 'running']).toContain( + egressAfterStart?.individual_recording?.status, + ); + expect(stopRecording).toHaveBeenCalledWith({ + type: call.type, + id: call.id, + recording_type: 'individual', + }); + // Stopped right after the start: nothing has been uploaded yet. + expect(response.recordings).toEqual([]); + }); + + it('sets individual recording output types on a call and a call type', async () => { + const call = await createCall(); + // The docs update a call type: run it on a test call type. + const recordingCallType = await createCallType(client, cleanup); + const updateCall = vi.spyOn(client.video, 'updateCall'); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/07-recording/03-individual_track.md" heading="Individual recording settings" tab="Node.js" index=1 + // COPY: recordingCallType="" + // Per‑call override — include all output types + await call.update({ + settings_override: { + individual_recording: { + mode: 'available', + output_types: [ + 'audio_only', + 'video_only', + 'audio_video', + 'screenshare_audio_only', + 'screenshare_video_only', + 'screenshare_audio_video', + ], + }, + }, + }); + + // Default for a call type — only combined audio+video outputs + await client.video.updateCallType({ + name: recordingCallType, + settings: { + individual_recording: { + mode: 'auto-on', + output_types: ['audio_video', 'screenshare_audio_video'], + }, + }, + }); + // #endregion snippet + + const callResponse = await (updateCall.mock.results[0].value as ReturnType< + typeof client.video.updateCall + >); + expect(callResponse.call.cid).toBe(call.cid); + expect(callResponse.call.settings.individual_recording).toEqual({ + mode: 'available', + output_types: [ + 'audio_only', + 'video_only', + 'audio_video', + 'screenshare_audio_only', + 'screenshare_video_only', + 'screenshare_audio_video', + ], + }); + const typeResponse = await (updateCallType.mock.results[0] + .value as ReturnType); + expect(typeResponse.name).toBe(recordingCallType); + expect(typeResponse.settings.individual_recording).toEqual({ + mode: 'auto-on', + output_types: ['audio_video', 'screenshare_audio_video'], + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/07-recording/04-raw.test.ts b/__tests__/docs-snippets/pages/video/api/07-recording/04-raw.test.ts new file mode 100644 index 0000000..2a61d03 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/07-recording/04-raw.test.ts @@ -0,0 +1,144 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createCallType } from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { joinCallAsUser } from '../../../../helpers/video'; +import type { CallResponse } from '../../../../../../index'; + +const DOCS = 'video/api/07-recording/04-raw.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** A `default` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createCall = async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ data: { created_by_id: johnId } }); + return call; + }; + + /** + * A call john is a member of and has joined over REST: recording needs a call + * session (see AGENTS.md). Stops the raw recording in cleanup in case the + * test fails before its stop. + */ + const createJoinedCall = async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }); + await joinCallAsUser(call, johnId); + cleanup.add(() => + call.stopRecording({ recording_type: 'raw' }).catch(() => undefined), + ); + return call; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts and stops a raw recording and lists the recordings', async () => { + const call = await createJoinedCall(); + // Read the egress state right after the start, before the stop. + const original = client.video.startRecording.bind(client.video); + let egressAfterStart: CallResponse['egress'] | undefined; + vi.spyOn(client.video, 'startRecording').mockImplementation( + async (request) => { + const response = await original(request); + egressAfterStart = (await call.get()).call.egress; + return response; + }, + ); + const stopRecording = vi.spyOn(client.video, 'stopRecording'); + + // #region snippet docs="video/api/07-recording/04-raw.md" heading="Quickstart" tab="Node.js" index=1 + // Start raw recording + // Triggers webhook event: call.recording_started + await call.startRecording({ recording_type: 'raw' }); + + // Stop raw recording + // Triggers webhook event: call.recording_stopped + // When processing completes: call.recording_ready (contains archive URL) + await call.stopRecording({ recording_type: 'raw' }); + + // List all recordings for this call + const response = await call.listRecordings(); + + // Download using the URL from response.recordings or the call.recording_ready webhook payload + // response.recordings[0].url + // #endregion snippet + + expect(['starting', 'running']).toContain( + egressAfterStart?.raw_recording?.status, + ); + expect(stopRecording).toHaveBeenCalledWith({ + type: call.type, + id: call.id, + recording_type: 'raw', + }); + // Stopped right after the start: nothing has been uploaded yet. + expect(response.recordings).toEqual([]); + }); + + it('sets raw recording on a call and a call type', async () => { + const call = await createCall(); + // The docs update a call type: run it on a test call type. + const recordingCallType = await createCallType(client, cleanup); + const updateCall = vi.spyOn(client.video, 'updateCall'); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/07-recording/04-raw.md" heading="Raw recording settings" tab="Node.js" index=1 + // COPY: recordingCallType="" + // Per-call override + await call.update({ + settings_override: { + raw_recording: { + mode: 'available', + }, + }, + }); + + // Default for a call type + await client.video.updateCallType({ + name: recordingCallType, + settings: { + raw_recording: { + mode: 'auto-on', + }, + }, + }); + // #endregion snippet + + const callResponse = await (updateCall.mock.results[0].value as ReturnType< + typeof client.video.updateCall + >); + expect(callResponse.call.cid).toBe(call.cid); + expect(callResponse.call.settings.raw_recording.mode).toBe('available'); + const typeResponse = await (updateCallType.mock.results[0] + .value as ReturnType); + expect(typeResponse.name).toBe(recordingCallType); + expect(typeResponse.settings.raw_recording.mode).toBe('auto-on'); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/07-recording/05-audio_only.test.ts b/__tests__/docs-snippets/pages/video/api/07-recording/05-audio_only.test.ts new file mode 100644 index 0000000..a23b7bb --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/07-recording/05-audio_only.test.ts @@ -0,0 +1,210 @@ +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 { joinCallAsUser } from '../../../../helpers/video'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'video/api/07-recording/05-audio_only.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** + * A `default` call john is a member of and has joined over REST: recording + * needs a call session (see AGENTS.md). Every recording type is stopped in + * cleanup (some snippets only start one). + */ + const createJoinedCall = async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }); + await joinCallAsUser(call, johnId); + for (const recording_type of ['composite', 'individual', 'raw'] as const) { + cleanup.add(() => + call.stopRecording({ recording_type }).catch(() => undefined), + ); + } + return call; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts and stops audio only recordings of every type', async () => { + const call = await createJoinedCall(); + const startRecording = vi.spyOn(client.video, 'startRecording'); + const stopRecording = vi.spyOn(client.video, 'stopRecording'); + + // #region snippet docs="video/api/07-recording/05-audio_only.md" heading="Quickstart" tab="Node.js" index=1 + // Option 1: Composite audio (single mixed MP3) + await call.update({ + settings_override: { + recording: { mode: 'available', audio_only: true }, + }, + }); + // Triggers: call.recording_started → call.recording_stopped → call.recording_ready + await call.startRecording({ recording_type: 'composite' }); + await call.stopRecording({ recording_type: 'composite' }); + + // Option 2: Individual audio (separate file per participant) + await call.update({ + settings_override: { + individual_recording: { + mode: 'available', + output_types: ['audio_only', 'screenshare_audio_only'], + }, + }, + }); + // Triggers: call.recording_started → call.recording_stopped → call.recording_ready (one per file) + await call.startRecording({ recording_type: 'individual' }); + await call.stopRecording({ recording_type: 'individual' }); + + // Option 3: Raw audio (unprocessed, requires CLI post-processing) + await call.update({ + settings_override: { + raw_recording: { mode: 'available', audio_only: true }, + }, + }); + // Triggers: call.recording_started → call.recording_stopped → call.recording_ready + await call.startRecording({ recording_type: 'raw' }); + await call.stopRecording({ recording_type: 'raw' }); + + // List all recordings for this call + const response = await call.listRecordings(); + + // Download using the URL from response.recordings or the call.recording_ready webhook payload + // response.recordings[0].url + // #endregion snippet + + const types = ['composite', 'individual', 'raw']; + expect(startRecording.mock.calls.map(([r]) => r.recording_type)).toEqual( + types, + ); + expect(stopRecording.mock.calls.map(([r]) => r.recording_type)).toEqual( + types, + ); + const { call: data } = await call.get(); + expect(data.settings.recording.audio_only).toBe(true); + expect(data.settings.individual_recording.output_types).toEqual([ + 'audio_only', + 'screenshare_audio_only', + ]); + expect(data.settings.raw_recording.audio_only).toBe(true); + // Every recording was stopped right after its start: nothing uploaded yet. + expect(response.recordings).toEqual([]); + }); + + it('starts an audio only composite recording', async () => { + const call = await createJoinedCall(); + + // #region snippet docs="video/api/07-recording/05-audio_only.md" heading="Composite audio" tab="Node.js" index=1 + // Enable audio-only composite recording + await call.update({ + settings_override: { + recording: { + mode: 'available', + audio_only: true, + }, + }, + }); + + // Start recording + // Triggers webhook event: call.recording_started + await call.startRecording({ recording_type: 'composite' }); + + // When stopped: call.recording_stopped + // When ready: call.recording_ready (contains recording URL) + // #endregion snippet + + // `recording` turns true a few seconds after the start (2026-10-16). + const data = await retry( + async () => { + const { call: current } = await call.get(); + if (!current.recording) throw new Error('not recording yet'); + return current; + }, + { timeout: 30000 }, + ); + expect(data.recording).toBe(true); + expect(data.settings.recording.audio_only).toBe(true); + }); + + it('starts an audio only individual recording', async () => { + const call = await createJoinedCall(); + + // #region snippet docs="video/api/07-recording/05-audio_only.md" heading="Individual audio tracks" tab="Node.js" index=1 + // Enable individual audio-only recording + await call.update({ + settings_override: { + individual_recording: { + mode: 'available', + output_types: ['audio_only', 'screenshare_audio_only'], + }, + }, + }); + + // Start recording + // Triggers webhook event: call.recording_started + await call.startRecording({ recording_type: 'individual' }); + + // When stopped: call.recording_stopped + // When ready: call.recording_ready (one event per file, contains recording URL) + // #endregion snippet + + const { call: data } = await call.get(); + expect(data.settings.individual_recording.output_types).toEqual([ + 'audio_only', + 'screenshare_audio_only', + ]); + expect(data.egress.individual_recording).toBeTruthy(); + }); + + it('starts an audio only raw recording', async () => { + const call = await createJoinedCall(); + + // #region snippet docs="video/api/07-recording/05-audio_only.md" heading="Raw audio" tab="Node.js" index=1 + // Enable raw recording with audio only + await call.update({ + settings_override: { + raw_recording: { + mode: 'available', + audio_only: true, + }, + }, + }); + + // Start recording + // Triggers webhook event: call.recording_started + await call.startRecording({ recording_type: 'raw' }); + + // When stopped: call.recording_stopped + // When ready: call.recording_ready (contains archive URL) + // #endregion snippet + + const { call: data } = await call.get(); + expect(data.settings.raw_recording.audio_only).toBe(true); + expect(data.egress.raw_recording).toBeTruthy(); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/07-recording/06-frame_recording.test.ts b/__tests__/docs-snippets/pages/video/api/07-recording/06-frame_recording.test.ts new file mode 100644 index 0000000..536352a --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/07-recording/06-frame_recording.test.ts @@ -0,0 +1,109 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createCallType } from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { joinCallAsUser } from '../../../../helpers/video'; +import { retry } from '../../../../helpers/wait'; +import type { CallResponse } from '../../../../../../index'; + +const DOCS = 'video/api/07-recording/06-frame_recording.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 }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts and stops frame recording', async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }); + // Frame recording needs a call session: join over REST (see AGENTS.md). + await joinCallAsUser(call, johnId); + cleanup.add(() => call.stopFrameRecording().catch(() => undefined)); + // Read the egress state right after the start, before the stop. + const original = client.video.startFrameRecording.bind(client.video); + let egressAfterStart: CallResponse['egress'] | undefined; + vi.spyOn(client.video, 'startFrameRecording').mockImplementation( + async (request) => { + const response = await original(request); + egressAfterStart = (await call.get()).call.egress; + return response; + }, + ); + + // #region snippet docs="video/api/07-recording/06-frame_recording.md" heading="Quickstart" tab="Node.js" index=1 + // Start frame recording + // Triggers webhook event: call.frame_recording_started + await call.startFrameRecording(); + + // Frames are delivered via: call.frame_recording_frame_ready (every 2 seconds per participant) + + // Stop frame recording + // Triggers webhook event: call.frame_recording_stopped + await call.stopFrameRecording(); + // #endregion snippet + + expect(['starting', 'running']).toContain( + egressAfterStart?.frame_recording?.status, + ); + const egress = await retry(async () => { + const { call: data } = await call.get(); + if (data.egress.frame_recording) throw new Error('still recording'); + return data.egress; + }); + expect(egress.frame_recording).toBeFalsy(); + }); + + it('runs the frame recorder automatically for all calls of a call type', async () => { + // The docs update the built-in `default` type: run it on a test call type. + const frameCallType = await createCallType(client, cleanup); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/07-recording/06-frame_recording.md" heading="Running frame recorder automatically for all calls" tab="Node.js" index=1 + // COPY: frameCallType="default" + await client.video.updateCallType({ + name: frameCallType, + settings: { + frame_recording: { + mode: 'auto-on', + capture_interval_in_seconds: 5, + quality: '720p', + }, + }, + }); + // #endregion snippet + + const response = await (updateCallType.mock.results[0].value as ReturnType< + typeof client.video.updateCallType + >); + expect(response.name).toBe(frameCallType); + expect(response.settings.frame_recording).toMatchObject({ + mode: 'auto-on', + capture_interval_in_seconds: 5, + quality: '720p', + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/07-recording/07-manage_recordings.test.ts b/__tests__/docs-snippets/pages/video/api/07-recording/07-manage_recordings.test.ts new file mode 100644 index 0000000..9c9e1ee --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/07-recording/07-manage_recordings.test.ts @@ -0,0 +1,92 @@ +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 { joinCallAsUser } from '../../../../helpers/video'; +import { retry } from '../../../../helpers/wait'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const DOCS = 'video/api/07-recording/07-manage_recordings.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** A `default` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createCall = async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ data: { created_by_id: johnId } }); + return call; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + it('lists the recordings of a call', async () => { + const call = await createCall(); + const listRecordings = vi.spyOn(client.video, 'listRecordings'); + + // #region snippet docs="video/api/07-recording/07-manage_recordings.md" heading="List recordings" tab="Node.js" index=1 + await call.listRecordings(); + // #endregion snippet + + expect(listRecordings).toHaveBeenCalledWith({ + id: call.id, + type: call.type, + }); + const response = await (listRecordings.mock.results[0].value as ReturnType< + typeof client.video.listRecordings + >); + // A call nobody joined has no recordings. + expect(response.recordings).toEqual([]); + listRecordings.mockRestore(); + }); + + it('deletes a recording', { timeout: 120_000 }, async () => { + // A composite recording of a few seconds on a call with a session (REST + // join, see AGENTS.md) shows up in listRecordings within ~15s (2026-10-16). + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }); + await joinCallAsUser(call, johnId); + cleanup.add(() => + call + .stopRecording({ recording_type: 'composite' }) + .catch(() => undefined), + ); + await call.startRecording({ recording_type: 'composite' }); + await sleep(10000); + await call.stopRecording({ recording_type: 'composite' }); + const [recording] = await retry( + async () => { + const { recordings } = await call.listRecordings(); + if (recordings.length === 0) throw new Error('no recording yet'); + return recordings; + }, + { timeout: 90000, interval: 5000 }, + ); + const sessionId = recording.session_id; + const recordingFilename = recording.filename; + + // #region snippet docs="video/api/07-recording/07-manage_recordings.md" heading="Delete recording" tab="Node.js" index=1 + // COPY: sessionId="", recordingFilename="" + await call.deleteRecording({ + session: sessionId, + filename: recordingFilename, + }); + // #endregion snippet + + const response = await call.listRecordings(); + expect(response.recordings).toEqual([]); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/07-recording/08-storage.test.ts b/__tests__/docs-snippets/pages/video/api/07-recording/08-storage.test.ts new file mode 100644 index 0000000..acf3793 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/07-recording/08-storage.test.ts @@ -0,0 +1,307 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + createCallType, + retryOnCallTypeMissing, +} from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { deleteExternalStorageIfExists } from '../../../../helpers/server'; +import { joinCallAsUser } from '../../../../helpers/video'; + +const DOCS = 'video/api/07-recording/08-storage.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** + * External storages are app-wide and not in the leak check: every storage + * this file creates gets a `uniqueId` name, is deleted in cleanup and + * checked for in `afterAll`. + */ + const storageNames: string[] = []; + const storageName = (name: string) => { + const id = uniqueId(name); + storageNames.push(id); + return id; + }; + + const listStorages = async () => + (await client.listExternalStorage()).external_storages; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + cleanup.add(async () => { + // Storages of skipped tests were never created. + for (const name of storageNames) { + await deleteExternalStorageIfExists(client, name); + } + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + await cleanup.run(); + const left = Object.keys(await listStorages()).filter((name) => + storageNames.includes(name), + ); + if (left.length > 0) { + throw new Error(`LEAK: external storages ${left.join(', ')}`); + } + }); + + // The placeholder AWS keys are accepted on create, but the check fails with + // code 4 (2026-10-16). + it.skip('BLOCKED: CheckExternalStorage failed with error: "InvalidAccessKeyId: The AWS Access Key Id you provided does not exist in our records."', async () => { + const myS3 = storageName('my-s3'); + const myCallType = await createCallType(client, cleanup); + + // #region snippet docs="video/api/07-recording/08-storage.md" heading="Use your own storage" tab="Node.js" index=1 + // COPY: myS3="my-s3", myCallType="my-call-type" + // 1. create a new storage with all the required parameters + await client.createExternalStorage({ + bucket: 'my-bucket', + name: myS3, + storage_type: 's3', + path: 'directory_name/', + aws_s3: { + s3_region: 'us-east-1', + s3_api_key: 'my-access-key', + s3_secret: 'my-secret', + }, + }); + + // 2. (Optional) Check storage configuration for correctness + // In case of any errors, this will throw a ResponseError. + await client.checkExternalStorage({ + name: myS3, + }); + + // 3. update the call type to use the new storage + await client.video.updateCallType({ + name: myCallType, + external_storage: myS3, + }); + // #endregion snippet + + const callType = await client.video.getCallType({ name: myCallType }); + expect(callType.external_storage).toBe(myS3); + }); + + // The placeholder S3 storage is accepted by every start; the uploads fail + // later, which this test doesn't wait for. + it('starts transcription and recordings with a named storage', async () => { + const myStorage = storageName('my-storage'); + await client.createExternalStorage({ + bucket: 'my-bucket', + name: myStorage, + storage_type: 's3', + aws_s3: { s3_region: 'us-east-1' }, + }); + const storageCallType = await createCallType(client, cleanup); + const call = client.video.call(storageCallType, uniqueId('call')); + cleanup.calls.push(call.cid); + await retryOnCallTypeMissing(() => + call.getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }), + ); + // Egress needs a call session: join over REST (see AGENTS.md). + await retryOnCallTypeMissing(() => joinCallAsUser(call, johnId)); + cleanup.add(() => call.stopTranscription().catch(() => undefined)); + for (const recording_type of ['composite', 'individual', 'raw'] as const) { + cleanup.add(() => + call.stopRecording({ recording_type }).catch(() => undefined), + ); + } + // Calls into the new call type can still hit nodes that don't know it. + const originalTranscription = client.video.startTranscription.bind( + client.video, + ); + const startTranscription = vi + .spyOn(client.video, 'startTranscription') + .mockImplementation((request) => + retryOnCallTypeMissing(() => originalTranscription(request)), + ); + const originalRecording = client.video.startRecording.bind(client.video); + const startRecording = vi + .spyOn(client.video, 'startRecording') + .mockImplementation((request) => + retryOnCallTypeMissing(() => originalRecording(request)), + ); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/07-recording/08-storage.md" heading="Multiple storage providers and default storage" tab="Node.js" index=1 + // COPY: storageCallType="my-call-type", myStorage="my-storage" + // update the call type to use Stream-managed storage for recordings + await client.video.updateCallType({ + name: storageCallType, + external_storage: 'stream-s3', + }); + + // specify my-storage storage when starting call transcribing + await call.startTranscription({ + transcription_external_storage: myStorage, + }); + + // specify my-storage storage for composite recording + await call.startRecording({ + recording_type: 'composite', + recording_external_storage: myStorage, + }); + + // specify my-storage storage for individual recording + await call.startRecording({ + recording_type: 'individual', + recording_external_storage: myStorage, + }); + + // specify my-storage storage for raw recording + await call.startRecording({ + recording_type: 'raw', + recording_external_storage: myStorage, + }); + // #endregion snippet + + expect(startTranscription).toHaveBeenCalledWith({ + type: call.type, + id: call.id, + transcription_external_storage: myStorage, + }); + expect( + startRecording.mock.calls.map(([r]) => [ + r.recording_type, + r.recording_external_storage, + ]), + ).toEqual([ + ['composite', myStorage], + ['individual', myStorage], + ['raw', myStorage], + ]); + const { call: data } = await call.get(); + // With the placeholder storage the transcriber doesn't come up: + // `transcribing` stays false (without a storage it is true right away). + expect(data.egress.individual_recording).toBeTruthy(); + expect(data.egress.raw_recording).toBeTruthy(); + const callType = await (updateCallType.mock.results[0].value as ReturnType< + typeof client.video.updateCallType + >); + // `stream-s3` is the default storage: it reads back as no + // `external_storage` at all (2026-10-16). + expect(callType.name).toBe(storageCallType); + expect(callType.external_storage).toBeUndefined(); + }); + + it('creates an Amazon S3 storage', async () => { + const myS3 = storageName('my-s3'); + + // #region snippet docs="video/api/07-recording/08-storage.md" heading="Code example" tab="Node.js" index=1 + // COPY: myS3="my-s3" + await client.createExternalStorage({ + name: myS3, + storage_type: 's3', + bucket: 'my-bucket', + path: 'directory_name/', + aws_s3: { + s3_api_key: 'my-access-key', + s3_region: 'us-east-1', + s3_secret: 'my-secret', + }, + }); + // #endregion snippet + + expect((await listStorages())[myS3]).toMatchObject({ + name: myS3, + type: 's3', + bucket: 'my-bucket', + path: 'directory_name/', + }); + }); + + it('creates an S3 compatible storage with a custom endpoint', async () => { + const myS3 = storageName('my-s3'); + + // #region snippet docs="video/api/07-recording/08-storage.md" heading="S3 Compatible Storage (Minio)" tab="Node.js" index=1 + // COPY: myS3="my-s3" + await client.createExternalStorage({ + name: myS3, + storage_type: 's3', + bucket: 'my-bucket', + path: 'directory_name/', + aws_s3: { + s3_api_key: 'my-access-key', + s3_region: 'us-east-1', + s3_secret: 'my-secret', + s3_custom_endpoint_url: 'https://s3.us-east-1.amazonaws.com', + }, + }); + // #endregion snippet + + expect((await listStorages())[myS3]).toMatchObject({ + name: myS3, + type: 's3', + bucket: 'my-bucket', + path: 'directory_name/', + }); + }); + + // The GCS service account is validated on create (2026-10-16). + it.skip('BLOCKED: CreateExternalStorage failed with error: "invalid gcs credentials"', async () => { + const myGcs = storageName('my-gcs'); + + // #region snippet docs="video/api/07-recording/08-storage.md" heading="Code example" tab="Node.js" index=2 + // COPY: myGcs="my-gcs" + await client.createExternalStorage({ + bucket: 'my-bucket', + name: myGcs, + storage_type: 'gcs', + path: 'directory_name/', + gcs_credentials: 'content of the service account file', + }); + // #endregion snippet + + expect((await listStorages())[myGcs]).toMatchObject({ type: 'gcs' }); + }); + + it('creates an Azure Blob Storage storage', async () => { + const myAbs = storageName('my-abs'); + + // #region snippet docs="video/api/07-recording/08-storage.md" heading="Code example" tab="Node.js" index=3 + // COPY: myAbs="my-abs" + await client.createExternalStorage({ + name: myAbs, + storage_type: 'abs', + bucket: 'my-bucket', + path: 'directory_name/', + azure_blob: { + abs_account_name: '...', + abs_client_id: '...', + abs_client_secret: '...', + abs_tenant_id: '...', + }, + }); + // #endregion snippet + + expect((await listStorages())[myAbs]).toMatchObject({ + name: myAbs, + type: 'abs', + bucket: 'my-bucket', + path: 'directory_name/', + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/08-transcription/01-transcribing_calls.test.ts b/__tests__/docs-snippets/pages/video/api/08-transcription/01-transcribing_calls.test.ts new file mode 100644 index 0000000..c0bf307 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/08-transcription/01-transcribing_calls.test.ts @@ -0,0 +1,407 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createCallType } from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { joinCallAsUser } from '../../../../helpers/video'; +import { retry } from '../../../../helpers/wait'; +import type { CallResponse } from '../../../../../../index'; + +const DOCS = 'video/api/08-transcription/01-transcribing_calls.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** A `default` call with a `uniqueId` id, registered for cleanup (not created). */ + const newCall = () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + return call; + }; + + /** A `default` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createCall = async () => { + const call = newCall(); + await call.getOrCreate({ data: { created_by_id: johnId } }); + return call; + }; + + /** The awaited results of every call to a spied method. */ + const spyResults = (spy: { mock: { results: { value: unknown }[] } }) => + Promise.all(spy.mock.results.map((r) => r.value as Promise)); + + type UpdateCallResponse = Awaited>; + type UpdateCallTypeResponse = Awaited< + ReturnType + >; + + /** + * A `default` call john is a member of and has joined over REST: transcription + * and closed captions need a call session (see AGENTS.md). Both are stopped + * in cleanup in case the test fails before its stops. + */ + const createJoinedCall = async () => { + const call = newCall(); + await call.getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }); + await joinCallAsUser(call, johnId); + cleanup.add(() => call.stopTranscription().catch(() => undefined)); + cleanup.add(() => call.stopClosedCaptions().catch(() => undefined)); + return call; + }; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts and stops transcription and closed captions', async () => { + const call = await createJoinedCall(); + // Wait for each process to come up right after its start, before the stops + // (they turn true within a few seconds, 2026-10-16). + const states: Pick[] = []; + const waitFor = async (flag: 'transcribing' | 'captioning') => { + const data = await retry( + async () => { + const { call: current } = await call.get(); + if (!current[flag]) throw new Error(`not ${flag} yet`); + return current; + }, + { timeout: 30000 }, + ); + states.push({ + transcribing: data.transcribing, + captioning: data.captioning, + }); + }; + const originalTranscription = client.video.startTranscription.bind( + client.video, + ); + const startTranscription = vi + .spyOn(client.video, 'startTranscription') + .mockImplementation(async (request) => { + const response = await originalTranscription(request); + if (startTranscription.mock.calls.length === 1) { + await waitFor('transcribing'); + } + return response; + }); + const originalCaptions = client.video.startClosedCaptions.bind( + client.video, + ); + vi.spyOn(client.video, 'startClosedCaptions').mockImplementation( + async (request) => { + const response = await originalCaptions(request); + await waitFor('captioning'); + return response; + }, + ); + const stopTranscription = vi.spyOn(client.video, 'stopTranscription'); + + // #region snippet docs="video/api/08-transcription/01-transcribing_calls.md" heading="Quick Start" tab="Node.js" index=1 + // start transcription with language + await call.startTranscription({ language: 'en' }); + + // start closed captions with language + await call.startClosedCaptions({ language: 'en' }); + + // stop transcription + await call.stopTranscription(); + + // stop closed captions + await call.stopClosedCaptions(); + + // you can also start or stop with a single API call + await call.startTranscription({ enable_closed_captions: true }); + await call.stopTranscription({ stop_closed_captions: true }); + // #endregion snippet + + expect(startTranscription.mock.calls.map(([r]) => r)).toEqual([ + { type: call.type, id: call.id, language: 'en' }, + { type: call.type, id: call.id, enable_closed_captions: true }, + ]); + expect(stopTranscription.mock.calls[1][0]).toEqual({ + type: call.type, + id: call.id, + stop_closed_captions: true, + }); + expect(states).toEqual([ + { transcribing: true, captioning: false }, + { transcribing: true, captioning: true }, + ]); + const { call: data } = await call.get(); + expect(data.transcribing).toBe(false); + expect(data.captioning).toBe(false); + }); + + it('sets the transcription language', async () => { + const call = newCall(); + // The docs update the built-in `default` type: run it on a test call type. + const languageCallType = await createCallType(client, cleanup); + const userId = johnId; + const getOrCreateCall = vi.spyOn(client.video, 'getOrCreateCall'); + const updateCall = vi.spyOn(client.video, 'updateCall'); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + // The fence creates the call itself: join it (REST, see AGENTS.md) right + // before its startTranscription, which needs a call session. + const original = client.video.startTranscription.bind(client.video); + const startTranscription = vi + .spyOn(client.video, 'startTranscription') + .mockImplementation(async (request) => { + await joinCallAsUser(call, userId); + return original(request); + }); + cleanup.add(() => call.stopTranscription().catch(() => undefined)); + + // #region snippet docs="video/api/08-transcription/01-transcribing_calls.md" heading="Transcription language" tab="Node.js" index=1 + // COPY: languageCallType="default", userId="user-id" + // 1. set the language for all calls of the default type to "fr" + await client.video.updateCallType({ + name: languageCallType, + settings: { + transcription: { + language: 'fr', + }, + }, + }); + + // 2. create a call and set its language to "fr" + await call.getOrCreate({ + data: { + created_by_id: userId, + settings_override: { + transcription: { + language: 'fr', + }, + }, + }, + }); + + // 3. update an existing call and set its language to "fr" + await call.update({ + settings_override: { + transcription: { + language: 'fr', + }, + }, + }); + + // 4. start transcription and set language to "fr" + await call.startTranscription({ language: 'fr' }); + // #endregion snippet + + const [type] = await spyResults(updateCallType); + expect(type.settings.transcription.language).toBe('fr'); + const [created] = + await spyResults< + Awaited> + >(getOrCreateCall); + expect(created.created).toBe(true); + expect(created.call.settings.transcription.language).toBe('fr'); + const [updated] = await spyResults(updateCall); + expect(updated.call.settings.transcription.language).toBe('fr'); + expect(startTranscription.mock.calls[0][0]).toMatchObject({ + language: 'fr', + }); + await startTranscription.mock.results[0].value; + // transcribing turns true within a few seconds of the start (2026-10-16). + const data = await retry( + async () => { + const { call: current } = await call.get(); + if (!current.transcribing) throw new Error('not transcribing yet'); + return current; + }, + { timeout: 30000 }, + ); + expect(data.transcribing).toBe(true); + expect(data.settings.transcription.language).toBe('fr'); + }); + + it('configures the speech segment settings', async () => { + const call = await createCall(); + const segmentCallType = await createCallType(client, cleanup); + const updateCall = vi.spyOn(client.video, 'updateCall'); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/08-transcription/01-transcribing_calls.md" heading="Configuration" tab="Node.js" index=1 + // COPY: segmentCallType="default" + // Per call type (default) + await client.video.updateCallType({ + name: segmentCallType, + settings: { + transcription: { + speech_segment_config: { + max_speech_caption_ms: 9000, + silence_duration_ms: 700, + }, + }, + }, + }); + + // Per call (override) + await call.update({ + settings_override: { + transcription: { + speech_segment_config: { + max_speech_caption_ms: 5000, + silence_duration_ms: 600, + }, + }, + }, + }); + // #endregion snippet + + const [type] = await spyResults(updateCallType); + expect(type.settings.transcription.speech_segment_config).toEqual({ + max_speech_caption_ms: 9000, + silence_duration_ms: 700, + }); + const [updated] = await spyResults(updateCall); + expect(updated.call.settings.transcription.speech_segment_config).toEqual({ + max_speech_caption_ms: 5000, + silence_duration_ms: 600, + }); + }); + + it('lists call transcriptions', async () => { + const call = await createCall(); + const listTranscriptions = vi.spyOn(client.video, 'listTranscriptions'); + + // #region snippet docs="video/api/08-transcription/01-transcribing_calls.md" heading="List call transcriptions" tab="Node.js" index=1 + await call.listTranscriptions(); + // #endregion snippet + + const [response] = + await spyResults< + Awaited> + >(listTranscriptions); + expect(response.transcriptions).toEqual([]); + }); + + it('returns an error when deleting a transcription that does not exist', async () => { + const call = await createCall(); + // "An error will be returned if the transcription doesn't exist": record + // the error of the docs' placeholder session and filename. + const original = client.video.deleteTranscription.bind(client.video); + const errors: unknown[] = []; + const deleteTranscription = vi + .spyOn(client.video, 'deleteTranscription') + .mockImplementation(async (request) => { + try { + return await original(request); + } catch (error) { + errors.push(error); + return undefined as never; + } + }); + + // #region snippet docs="video/api/08-transcription/01-transcribing_calls.md" heading="Delete call transcription" tab="Node.js" index=1 + await call.deleteTranscription({ + session: '', + filename: '', + }); + // #endregion snippet + + expect(deleteTranscription.mock.calls[0][0]).toMatchObject({ + type: 'default', + id: call.id, + session: '', + filename: '', + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: 16 }); + expect(String(errors[0])).toContain("transcription doesn't exist"); + }); + + it('enables, disables and configures transcription', async () => { + const call = await createCall(); + // The docs update a call type: run it on a test call type. + const enableCallType = await createCallType(client, cleanup); + const updateCall = vi.spyOn(client.video, 'updateCall'); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/08-transcription/01-transcribing_calls.md" heading="Enabling, disabling, automatically start" tab="Node.js" index=1 + // COPY: enableCallType="" + // Disable on call level + await call.update({ + settings_override: { + transcription: { + mode: 'disabled', + closed_caption_mode: 'disabled', + }, + }, + }); + + // Disable on call type level + await client.video.updateCallType({ + name: enableCallType, + settings: { + transcription: { + language: 'en', + mode: 'disabled', + closed_caption_mode: 'disabled', + }, + }, + }); + + // Enable + await call.update({ + settings_override: { + transcription: { + language: 'en', + mode: 'available', + closed_caption_mode: 'available', + }, + }, + }); + + // Other settings + await call.update({ + settings_override: { + transcription: { + language: 'en', + mode: 'auto-on', + closed_caption_mode: 'auto-on', + }, + }, + }); + // #endregion snippet + + const calls = await spyResults(updateCall); + expect( + calls.map((r) => [ + r.call.settings.transcription.mode, + r.call.settings.transcription.closed_caption_mode, + ]), + ).toEqual([ + ['disabled', 'disabled'], + ['available', 'available'], + ['auto-on', 'auto-on'], + ]); + const [type] = await spyResults(updateCallType); + expect(type.name).toBe(enableCallType); + expect(type.settings.transcription).toMatchObject({ + language: 'en', + mode: 'disabled', + closed_caption_mode: 'disabled', + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/08-transcription/02-storage.test.ts b/__tests__/docs-snippets/pages/video/api/08-transcription/02-storage.test.ts new file mode 100644 index 0000000..b076b0a --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/08-transcription/02-storage.test.ts @@ -0,0 +1,297 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + createCallType, + retryOnCallTypeMissing, +} from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { deleteExternalStorageIfExists } from '../../../../helpers/server'; +import { joinCallAsUser } from '../../../../helpers/video'; + +const DOCS = 'video/api/08-transcription/02-storage.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** + * External storages are app-wide: every storage this file creates gets a + * `uniqueId` name (found by the leak check) and is deleted in cleanup. + */ + const storageNames: string[] = []; + const storageName = (name: string) => { + const id = uniqueId(name); + storageNames.push(id); + return id; + }; + + const listStorages = async () => + (await client.listExternalStorage()).external_storages; + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + cleanup.add(async () => { + for (const name of storageNames) { + await deleteExternalStorageIfExists(client, name); + } + }); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // The placeholder AWS keys are accepted on create, but the check fails with + // code 4 (2026-10-16). + it.skip('BLOCKED: CheckExternalStorage failed with error: "InvalidAccessKeyId: The AWS Access Key Id you provided does not exist in our records."', async () => { + const myS3 = storageName('my-s3'); + const myCallType = await createCallType(client, cleanup); + + // #region snippet docs="video/api/08-transcription/02-storage.md" heading="Use your own storage" tab="Node.js" index=1 + // COPY: myS3="my-s3", myCallType="my-call-type" + // 1. create a new storage with all the required parameters + await client.createExternalStorage({ + bucket: 'my-bucket', + name: myS3, + storage_type: 's3', + path: 'directory_name/', + aws_s3: { + s3_region: 'us-east-1', + s3_api_key: 'my-access-key', + s3_secret: 'my-secret', + }, + }); + + // 2. (Optional) Check storage configuration for correctness + // In case of any errors, this will throw a ResponseError. + await client.checkExternalStorage({ + name: myS3, + }); + + // 3. update the call type to use the new storage + await client.video.updateCallType({ + name: myCallType, + external_storage: myS3, + }); + // #endregion snippet + + const callType = await client.video.getCallType({ name: myCallType }); + expect(callType.external_storage).toBe(myS3); + }); + + // The placeholder S3 storage is accepted by every start; the uploads fail + // later, which this test doesn't wait for. + it('starts transcription and recordings with a named storage', async () => { + const myStorage = storageName('my-storage'); + await client.createExternalStorage({ + bucket: 'my-bucket', + name: myStorage, + storage_type: 's3', + aws_s3: { s3_region: 'us-east-1' }, + }); + const storageCallType = await createCallType(client, cleanup); + const call = client.video.call(storageCallType, uniqueId('call')); + cleanup.calls.push(call.cid); + await retryOnCallTypeMissing(() => + call.getOrCreate({ + data: { created_by_id: johnId, members: [{ user_id: johnId }] }, + }), + ); + // Egress needs a call session: join over REST (see AGENTS.md). + await retryOnCallTypeMissing(() => joinCallAsUser(call, johnId)); + cleanup.add(() => call.stopTranscription().catch(() => undefined)); + for (const recording_type of ['composite', 'individual', 'raw'] as const) { + cleanup.add(() => + call.stopRecording({ recording_type }).catch(() => undefined), + ); + } + // Calls into the new call type can still hit nodes that don't know it. + const originalTranscription = client.video.startTranscription.bind( + client.video, + ); + const startTranscription = vi + .spyOn(client.video, 'startTranscription') + .mockImplementation((request) => + retryOnCallTypeMissing(() => originalTranscription(request)), + ); + const originalRecording = client.video.startRecording.bind(client.video); + const startRecording = vi + .spyOn(client.video, 'startRecording') + .mockImplementation((request) => + retryOnCallTypeMissing(() => originalRecording(request)), + ); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + + // #region snippet docs="video/api/08-transcription/02-storage.md" heading="Multiple storage providers and default storage" tab="Node.js" index=1 + // COPY: storageCallType="my-call-type", myStorage="my-storage" + // update the call type to use Stream-managed storage for recordings + await client.video.updateCallType({ + name: storageCallType, + external_storage: 'stream-s3', + }); + + // specify my-storage storage when starting call transcribing + await call.startTranscription({ + transcription_external_storage: myStorage, + }); + + // specify my-storage storage for composite recording + await call.startRecording({ + recording_type: 'composite', + recording_external_storage: myStorage, + }); + + // specify my-storage storage for individual recording + await call.startRecording({ + recording_type: 'individual', + recording_external_storage: myStorage, + }); + + // specify my-storage storage for raw recording + await call.startRecording({ + recording_type: 'raw', + recording_external_storage: myStorage, + }); + // #endregion snippet + + expect(startTranscription).toHaveBeenCalledWith({ + type: call.type, + id: call.id, + transcription_external_storage: myStorage, + }); + expect( + startRecording.mock.calls.map(([r]) => [ + r.recording_type, + r.recording_external_storage, + ]), + ).toEqual([ + ['composite', myStorage], + ['individual', myStorage], + ['raw', myStorage], + ]); + const { call: data } = await call.get(); + // With the placeholder storage the transcriber doesn't come up: + // `transcribing` stays false (without a storage it is true right away). + expect(data.egress.individual_recording).toBeTruthy(); + expect(data.egress.raw_recording).toBeTruthy(); + const callType = await (updateCallType.mock.results[0].value as ReturnType< + typeof client.video.updateCallType + >); + // `stream-s3` is the default storage: it reads back as no + // `external_storage` at all (2026-10-16). + expect(callType.name).toBe(storageCallType); + expect(callType.external_storage).toBeUndefined(); + }); + + it('creates an Amazon S3 storage', async () => { + const myS3 = storageName('my-s3'); + + // #region snippet docs="video/api/08-transcription/02-storage.md" heading="Code example" tab="Node.js" index=1 + // COPY: myS3="my-s3" + await client.createExternalStorage({ + name: myS3, + storage_type: 's3', + bucket: 'my-bucket', + path: 'directory_name/', + aws_s3: { + s3_api_key: 'my-access-key', + s3_region: 'us-east-1', + s3_secret: 'my-secret', + }, + }); + // #endregion snippet + + expect((await listStorages())[myS3]).toMatchObject({ + name: myS3, + type: 's3', + bucket: 'my-bucket', + path: 'directory_name/', + }); + }); + + it('creates an S3 compatible storage with a custom endpoint', async () => { + const myS3 = storageName('my-s3'); + + // #region snippet docs="video/api/08-transcription/02-storage.md" heading="S3 Compatible Storage (Minio)" tab="Node.js" index=1 + // COPY: myS3="my-s3" + await client.createExternalStorage({ + name: myS3, + storage_type: 's3', + bucket: 'my-bucket', + path: 'directory_name/', + aws_s3: { + s3_api_key: 'my-access-key', + s3_region: 'us-east-1', + s3_secret: 'my-secret', + s3_custom_endpoint_url: 'https://s3.us-east-1.amazonaws.com', + }, + }); + // #endregion snippet + + expect((await listStorages())[myS3]).toMatchObject({ + name: myS3, + type: 's3', + bucket: 'my-bucket', + path: 'directory_name/', + }); + }); + + // The GCS service account is validated on create (2026-10-16). + it.skip('BLOCKED: CreateExternalStorage failed with error: "invalid gcs credentials"', async () => { + const myGcs = storageName('my-gcs'); + + // #region snippet docs="video/api/08-transcription/02-storage.md" heading="Code example" tab="Node.js" index=2 + // COPY: myGcs="my-gcs" + await client.createExternalStorage({ + bucket: 'my-bucket', + name: myGcs, + storage_type: 'gcs', + path: 'directory_name/', + gcs_credentials: 'content of the service account file', + }); + // #endregion snippet + + expect((await listStorages())[myGcs]).toMatchObject({ type: 'gcs' }); + }); + + it('creates an Azure Blob Storage storage', async () => { + const myAbs = storageName('my-abs'); + + // #region snippet docs="video/api/08-transcription/02-storage.md" heading="Code example" tab="Node.js" index=3 + // COPY: myAbs="my-abs" + await client.createExternalStorage({ + name: myAbs, + storage_type: 'abs', + bucket: 'my-bucket', + path: 'directory_name/', + azure_blob: { + abs_account_name: '...', + abs_client_id: '...', + abs_client_secret: '...', + abs_tenant_id: '...', + }, + }); + // #endregion snippet + + expect((await listStorages())[myAbs]).toMatchObject({ + name: myAbs, + type: 'abs', + bucket: 'my-bucket', + path: 'directory_name/', + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/08-transcription/03-translated-captions.test.ts b/__tests__/docs-snippets/pages/video/api/08-transcription/03-translated-captions.test.ts new file mode 100644 index 0000000..32018df --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/08-transcription/03-translated-captions.test.ts @@ -0,0 +1,199 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + createCallType, + retryOnCallTypeMissing, +} from '../../../../helpers/call-types'; +import { getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { joinCallAsUser } from '../../../../helpers/video'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'video/api/08-transcription/03-translated-captions.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const hostId = uniqueId('host'); + + /** The awaited results of every call to a spied method. */ + const spyResults = (spy: { mock: { results: { value: unknown }[] } }) => + Promise.all(spy.mock.results.map((r) => r.value as Promise)); + + beforeAll(async () => { + cleanup.users.push(hostId); + await client.upsertUsers([{ id: hostId }]); + }); + + afterAll(() => cleanup.run()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('enables translated captions for a call and a call type', async () => { + // The docs create a `default` call and update the built-in `default` + // type: run both on a test call type. + const translationCallType = await createCallType(client, cleanup); + const callId = uniqueId('call'); + cleanup.calls.push(`${translationCallType}:${callId}`); + // A just-created call type can be unknown to some API nodes for a while. + const originalGetOrCreate = client.video.getOrCreateCall.bind(client.video); + const getOrCreateCall = vi + .spyOn(client.video, 'getOrCreateCall') + .mockImplementation((request) => + retryOnCallTypeMissing(() => originalGetOrCreate(request)), + ); + const updateCallType = vi.spyOn(client.video, 'updateCallType'); + // The fence creates the call itself: join it (REST, see AGENTS.md) right + // before its startClosedCaptions, which needs a call session. + const original = client.video.startClosedCaptions.bind(client.video); + let captioningAfterStart = false; + const startClosedCaptions = vi + .spyOn(client.video, 'startClosedCaptions') + .mockImplementation(async (request) => { + await retryOnCallTypeMissing(() => + joinCallAsUser({ type: request.type, id: request.id }, hostId), + ); + cleanup.add(() => + client.video + .stopClosedCaptions({ type: request.type, id: request.id }) + .catch(() => undefined), + ); + let response = await retryOnCallTypeMissing(() => original(request)); + // captioning turns true within a few seconds of the start (2026-10-16), + // but now and then it never does: stop and start once more. + const waitForCaptioning = () => + retry( + async () => { + const { call: current } = await client.video.getCall(request); + if (!current.captioning) throw new Error('not captioning yet'); + return current.captioning; + }, + { timeout: 30000 }, + ); + try { + captioningAfterStart = await waitForCaptioning(); + } catch { + await client.video + .stopClosedCaptions({ type: request.type, id: request.id }) + .catch(() => undefined); + response = await retryOnCallTypeMissing(() => original(request)); + captioningAfterStart = await waitForCaptioning(); + } + return response; + }); + + // #region snippet docs="video/api/08-transcription/03-translated-captions.md" heading="Enabling translated captions" tab="Node.js" index=1 + // COPY: translationCallType="default", hostId="host-id" + // enable translation for a single call + const call = client.video.call(translationCallType, callId); + await call.getOrCreate({ + data: { + created_by_id: hostId, + settings_override: { + transcription: { + mode: 'available', + closed_caption_mode: 'available', + language: 'en', + translation: { + enabled: true, + languages: ['es', 'fr'], + }, + }, + }, + }, + }); + + // closed captions must be running for translations to be produced + await call.startClosedCaptions(); + + // enable translation for all calls of a call type instead + await client.video.updateCallType({ + name: translationCallType, + settings: { + transcription: { + closed_caption_mode: 'available', + language: 'en', + translation: { + enabled: true, + languages: ['es', 'fr'], + }, + }, + }, + }); + // #endregion snippet + + const [created] = + await spyResults< + Awaited> + >(getOrCreateCall); + expect(created.created).toBe(true); + expect(created.call.settings.transcription).toMatchObject({ + mode: 'available', + closed_caption_mode: 'available', + language: 'en', + translation: { enabled: true, languages: ['es', 'fr'] }, + }); + expect(startClosedCaptions).toHaveBeenCalledTimes(1); + expect(captioningAfterStart).toBe(true); + const [type] = + await spyResults>>( + updateCallType, + ); + expect(type.settings.transcription).toMatchObject({ + closed_caption_mode: 'available', + language: 'en', + translation: { enabled: true, languages: ['es', 'fr'] }, + }); + }, 180_000); + + it('changes the translation languages during a call', async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ + data: { + created_by_id: hostId, + settings_override: { + transcription: { + closed_caption_mode: 'available', + translation: { enabled: true, languages: ['es', 'fr'] }, + }, + }, + }, + }); + const updateCall = vi.spyOn(client.video, 'updateCall'); + + // #region snippet docs="video/api/08-transcription/03-translated-captions.md" heading="Changing languages during a call" tab="Node.js" index=1 + // add German to a call that already translates to Spanish and French + await call.update({ + settings_override: { + transcription: { + translation: { + enabled: true, + languages: ['es', 'fr', 'de'], + }, + }, + }, + }); + // #endregion snippet + + const [updated] = + await spyResults>>( + updateCall, + ); + expect(updated.call.settings.transcription.translation).toEqual({ + enabled: true, + languages: ['es', 'fr', 'de'], + }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/09-GDPR and Privacy/03-calls.test.ts b/__tests__/docs-snippets/pages/video/api/09-GDPR and Privacy/03-calls.test.ts new file mode 100644 index 0000000..70652d5 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/09-GDPR and Privacy/03-calls.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, 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 { waitForTask } from '../../../../helpers/wait'; + +const DOCS = 'video/api/09-GDPR and Privacy/03-calls.md'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const johnId = uniqueId('john'); + + /** A `default` call with a `uniqueId` id, created by john and registered for cleanup. */ + const createCall = async () => { + const call = client.video.call('default', uniqueId('call')); + cleanup.calls.push(call.cid); + await call.getOrCreate({ data: { created_by_id: johnId } }); + return call; + }; + + /** The error of `call.get()`, or `undefined` if it succeeds. */ + const getError = (call: Awaited>) => + call.get().then( + () => undefined, + (error: unknown) => error, + ); + + beforeAll(async () => { + cleanup.users.push(johnId); + await client.upsertUsers([{ id: johnId }]); + }); + + afterAll(() => cleanup.run()); + + it('soft-deletes a call', async () => { + const call = await createCall(); + + // #region snippet docs="video/api/09-GDPR and Privacy/03-calls.md" heading="Soft delete" tab="Node.js" index=1 + // soft-delete a call + const resp = await call.delete({ + hard: false, + }); + // resp.call contains call information + // #endregion snippet + + expect(resp.call.cid).toBe(call.cid); + expect(resp.task_id).toBeUndefined(); + const error = await getError(call); + expect(error).toBeInstanceOf(StreamError); + expect(error).toMatchObject({ metadata: { responseCode: 404 } }); + }); + + it('hard-deletes a call', async () => { + const call = await createCall(); + + // #region snippet docs="video/api/09-GDPR and Privacy/03-calls.md" heading="Hard delete" tab="Node.js" index=1 + // hard-delete a call + const resp = await call.delete({ + hard: true, + }); + // resp.call contains call information + // resp.task_id is the ID to be used for monitoring the task + // #endregion snippet + + expect(resp.call.cid).toBe(call.cid); + expect(typeof resp.task_id).toBe('string'); + await waitForTask(client, resp.task_id ?? ''); + const error = await getError(call); + expect(error).toBeInstanceOf(StreamError); + expect(error).toMatchObject({ metadata: { responseCode: 404 } }); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/12-analytics/01-download-user-feedback.test.ts b/__tests__/docs-snippets/pages/video/api/12-analytics/01-download-user-feedback.test.ts new file mode 100644 index 0000000..7a6dbcf --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/12-analytics/01-download-user-feedback.test.ts @@ -0,0 +1,304 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { UserFeedbackResponse } from '../../../../../../index'; +import { createUserToken, getServerClient } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'video/api/12-analytics/01-download-user-feedback.md'; + +const VIDEO_API = 'https://video.stream-io-api.com/api/v2/video'; + +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const fooId = uniqueId('foo'); + const barId = uniqueId('bar'); + const call1 = client.video.call('default', uniqueId('call')); + const call2 = client.video.call('default', uniqueId('call')); + const call1Cid = call1.cid; + let call1SessionId = ''; + + /** + * Sends a request to the video API as `userId` (client-side endpoints). + * Feedback can only be submitted for a call session the user joined, and the + * server-side `collectUserFeedback` has no `user_id` ("user_id is missing"). + */ + const asUser = async (userId: string, path: string, body: object) => { + const response = await fetch( + `${VIDEO_API}${path}?api_key=${process.env.STREAM_API_KEY}`, + { + method: 'POST', + headers: { + Authorization: createUserToken(userId), + 'stream-auth-type': 'jwt', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }, + ); + const json = (await response.json()) as { + call?: { current_session_id: string }; + }; + if (!response.ok) { + throw new Error(`${path}: ${response.status} ${JSON.stringify(json)}`); + } + return json; + }; + + /** Joins the call over REST (creates a session, no WebRTC) and rates it. */ + const rate = async ( + call: typeof call1, + userId: string, + rating: number, + custom: Record, + ) => { + const joined = await asUser(userId, `/call/${call.type}/${call.id}/join`, { + location: 'AMS', + }); + await asUser(userId, `/call/${call.type}/${call.id}/feedback`, { + rating, + reason: `rated ${rating}`, + sdk: 'docs-test', + sdk_version: '1.0.0', + custom, + }); + return joined.call?.current_session_id ?? ''; + }; + + /** Feedback ordered as it was submitted in `beforeAll`. */ + const submitted: Array<{ cid: string; user_id: string; rating: number }> = []; + + /** Only the feedback this file submitted (feedback is app-wide). */ + const own = (items: UserFeedbackResponse[]) => + items.filter((f) => f.cid === call1.cid || f.cid === call2.cid); + + const summary = (items: UserFeedbackResponse[]) => + own(items).map(({ cid, user_id, rating }) => ({ cid, user_id, rating })); + + beforeAll(async () => { + cleanup.users.push(fooId, barId); + await client.upsertUsers([{ id: fooId }, { id: barId }]); + for (const call of [call1, call2]) { + cleanup.calls.push(call.cid); + await call.create({ data: { created_by_id: fooId } }); + } + + const ratings: Array<[typeof call1, string, number]> = [ + [call1, fooId, 1], + [call1, barId, 3], + [call2, fooId, 5], + [call2, barId, 4], + ]; + for (const [call, userId, rating] of ratings) { + const sessionId = await rate(call, userId, rating, { userId, rating }); + if (call === call1) call1SessionId = sessionId; + submitted.push({ cid: call.cid, user_id: userId, rating }); + } + + // Feedback is queryable shortly after it is submitted. + await retry(async () => { + const { user_feedback } = await client.video.queryUserFeedback({ + filter_conditions: { call_cid: { $in: [call1.cid, call2.cid] } }, + }); + if (user_feedback.length !== submitted.length) { + throw new Error(`${user_feedback.length} of ${submitted.length}`); + } + }); + }); + + afterAll(() => cleanup.run()); + + it('Download user feedback', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Download user feedback" tab="Node.js" + const response = await client.video.queryUserFeedback({}); + // #endregion snippet + + // Most recent first by default. + expect(summary(response.user_feedback)).toEqual([...submitted].reverse()); + }); + + it('Filter by call', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Filter by call" tab="Node.js" + // COPY: call1Cid="default:1234" + const callCID = call1Cid; + const response = await client.video.queryUserFeedback({ + filter_conditions: { + call_cid: callCID, + }, + }); + // #endregion snippet + + expect(response.user_feedback.map((f) => f.cid)).toEqual([ + call1.cid, + call1.cid, + ]); + }); + + it('Filter by call and session', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Filter by call" tab="Node.js" index=2 + // COPY: call1Cid="default:1234", call1SessionId="d1a5803b-6121-4fc2-b070-e5eed3277ed4" + const callCID = call1Cid; + const callSessionID = call1SessionId; + const response = await client.video.queryUserFeedback({ + filter_conditions: { + call_cid: callCID, + call_session_id: callSessionID, + }, + }); + // #endregion snippet + + expect(call1SessionId).not.toBe(''); + expect(response.user_feedback.map((f) => [f.cid, f.session_id])).toEqual([ + [call1.cid, call1SessionId], + [call1.cid, call1SessionId], + ]); + }); + + it('Filter by user', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Filter by user(s)" tab="Node.js" + // COPY: fooId="foo" + const userID = fooId; + const response = await client.video.queryUserFeedback({ + filter_conditions: { + user_id: userID, + }, + }); + // #endregion snippet + + expect(summary(response.user_feedback)).toEqual([ + { cid: call2.cid, user_id: fooId, rating: 5 }, + { cid: call1.cid, user_id: fooId, rating: 1 }, + ]); + expect(response.user_feedback.every((f) => f.user_id === fooId)).toBe(true); + }); + + it('Filter by multiple users', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Filter by user(s)" tab="Node.js" index=2 + // COPY: fooId="foo", barId="bar" + const users = [fooId, barId]; + const response = await client.video.queryUserFeedback({ + filter_conditions: { + user_id: { $in: users }, + }, + }); + // #endregion snippet + + expect(summary(response.user_feedback)).toEqual([...submitted].reverse()); + expect(response.user_feedback).toHaveLength(submitted.length); + }); + + it('Negative feedback', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Filter by rating" tab="Node.js" + const response = await client.video.queryUserFeedback({ + filter_conditions: { + rating: { $lt: 3 }, + }, + }); + // #endregion snippet + + expect(response.user_feedback.every((f) => f.rating < 3)).toBe(true); + expect(summary(response.user_feedback)).toEqual([ + { cid: call1.cid, user_id: fooId, rating: 1 }, + ]); + }); + + it('Neutral feedback', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Filter by rating" tab="Node.js" index=2 + const response = await client.video.queryUserFeedback({ + filter_conditions: { rating: 3 }, + }); + // #endregion snippet + + expect(response.user_feedback.every((f) => f.rating === 3)).toBe(true); + expect(summary(response.user_feedback)).toEqual([ + { cid: call1.cid, user_id: barId, rating: 3 }, + ]); + }); + + it('Positive feedback', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Filter by rating" tab="Node.js" index=3 + const response = await client.video.queryUserFeedback({ + filter_conditions: { + rating: { $gte: 4 }, + }, + }); + // #endregion snippet + + expect(response.user_feedback.every((f) => f.rating >= 4)).toBe(true); + expect(summary(response.user_feedback)).toEqual([ + { cid: call2.cid, user_id: barId, rating: 4 }, + { cid: call2.cid, user_id: fooId, rating: 5 }, + ]); + }); + + it('Combine multiple conditions', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Combine multiple conditions" tab="Node.js" + // COPY: fooId="foo" + const user = fooId; + const response = await client.video.queryUserFeedback({ + filter_conditions: { + user_id: user, + rating: { $lt: 3 }, + }, + }); + // #endregion snippet + + expect(summary(response.user_feedback)).toEqual([ + { cid: call1.cid, user_id: fooId, rating: 1 }, + ]); + expect(response.user_feedback).toHaveLength(1); + }); + + it('Specifying sort order', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Specifying sort order" tab="Node.js" + const response = await client.video.queryUserFeedback({ + sort: [ + // 1 = ascending order + // -1 = descending order (default for created_at) + { field: 'created_at', direction: 1 }, + ], + }); + // #endregion snippet + + // Earliest first: the order the feedback was submitted in. + expect(summary(response.user_feedback)).toEqual(submitted); + }); + + it('Specifying the maximum number of items to be retrieved', async () => { + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Specifying the maximum number of items to be retrieved" tab="Node.js" + const response = await client.video.queryUserFeedback({ limit: 5 }); + // #endregion snippet + + expect(response.user_feedback.length).toBeGreaterThanOrEqual( + submitted.length, + ); + expect(response.user_feedback.length).toBeLessThanOrEqual(5); + // More than 100 is rejected. + await expect( + client.video.queryUserFeedback({ limit: 101 }), + ).rejects.toMatchObject({ code: 4, metadata: { responseCode: 400 } }); + }); + + it('Downloading with custom data', async () => { + // Without `full`, custom data is left out. + const withoutFull = await client.video.queryUserFeedback({ + filter_conditions: { user_id: fooId }, + }); + expect(withoutFull.user_feedback.map((f) => f.custom)).toEqual([ + undefined, + undefined, + ]); + + // #region snippet docs="video/api/12-analytics/01-download-user-feedback.md" heading="Downloading with custom data" tab="Node.js" + const response = await client.video.queryUserFeedback({ full: true }); + // #endregion snippet + + expect(own(response.user_feedback).map((f) => f.custom)).toEqual( + [...submitted] + .reverse() + .map(({ user_id, rating }) => ({ userId: user_id, rating })), + ); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/12-analytics/03-call-attendance.test.ts b/__tests__/docs-snippets/pages/video/api/12-analytics/03-call-attendance.test.ts new file mode 100644 index 0000000..27e07f8 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/12-analytics/03-call-attendance.test.ts @@ -0,0 +1,166 @@ +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 { joinCallAsUser } from '../../../../helpers/video'; + +const DOCS = 'video/api/12-analytics/03-call-attendance.md'; + +/** + * Participant sessions come from the SFU's stats. A REST join (`joinCallAsUser`) + * starts a call session but no SFU participant, so the endpoint never has data + * for it (code 16 "session participant stats for call ... not found", still after + * ending the call and waiting a minute, 2026-10-16), and the app has no call + * stats reports to read instead. The single-call fences run against a real joined + * session: the spy records that 404, which proves the request itself (call, + * session, limit, filter) is accepted (a wrong session id gives "call session + * not found", `limit: 101` and a bad cursor give code 4). + */ +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const aliceId = uniqueId('alice'); + const attendanceCallId = uniqueId('call'); + let sessionId = ''; + + /** Records the API's rejection of each `queryCallParticipantSessions` call. */ + const recordRejections = () => { + const errors: unknown[] = []; + const original = client.video.queryCallParticipantSessions.bind( + client.video, + ); + const spy = vi + .spyOn(client.video, 'queryCallParticipantSessions') + .mockImplementation(async (request) => { + try { + return await original(request); + } catch (error) { + errors.push(error); + return undefined as never; + } + }); + return { errors, spy }; + }; + + const expectNoStatsYet = (errors: unknown[]) => { + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 16, + metadata: { responseCode: 404 }, + }); + expect(String(errors[0])).toContain( + `session participant stats for call default:${attendanceCallId}:${sessionId} not found`, + ); + }; + + beforeAll(async () => { + cleanup.users.push(aliceId); + await client.upsertUsers([{ id: aliceId }]); + const setupCall = client.video.call('default', attendanceCallId); + cleanup.calls.push(setupCall.cid); + await setupCall.create({ data: { created_by_id: aliceId } }); + sessionId = await joinCallAsUser(setupCall, aliceId); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Query call participant sessions', async () => { + const { errors, spy } = recordRejections(); + + // #region snippet docs="video/api/12-analytics/03-call-attendance.md" heading="Response overview" tab="Node.js" + // COPY: attendanceCallId="call-id", sessionId="" + const call = client.video.call('default', attendanceCallId); + const response = await call.queryCallParticipantSessions({ + session: sessionId, + limit: 25, + }); + // #endregion snippet + + expect(response).toBeUndefined(); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'default', + id: attendanceCallId, + session: sessionId, + limit: 25, + }); + expectNoStatsYet(errors); + }); + + it('Filtering', async () => { + const call = client.video.call('default', attendanceCallId); + const { errors, spy } = recordRejections(); + + // #region snippet docs="video/api/12-analytics/03-call-attendance.md" heading="Filtering" tab="Node.js" + // COPY: sessionId="", aliceId="alice" + const response = await call.queryCallParticipantSessions({ + session: sessionId, + filter_conditions: { user_id: { $eq: aliceId } }, + }); + // #endregion snippet + + expect(response).toBeUndefined(); + expect(spy.mock.calls[0][0]).toMatchObject({ + session: sessionId, + filter_conditions: { user_id: { $eq: aliceId } }, + }); + expectNoStatsYet(errors); + }); + + it.skip('BLOCKED: code 16 "session participant stats for call : not found" (no SFU participants without WebRTC): Filtering, aggregate over pages', async () => { + const call = client.video.call('default', attendanceCallId); + + // #region snippet docs="video/api/12-analytics/03-call-attendance.md" heading="Filtering" tab="Node.js" index=2 + // COPY: sessionId="", aliceId="alice" + const filter_conditions = { user_id: { $eq: aliceId } }; + let cursor; + let totalDuration = 0; + let totalSessions = 0; + + do { + const response = await call.queryCallParticipantSessions({ + session: sessionId, + limit: 100, + next: cursor, + filter_conditions, + }); + for (const s of response.participants_sessions) { + totalDuration += s.duration_in_seconds ?? 0; + totalSessions += 1; + } + cursor = response.next; + } while (cursor); + // #endregion snippet + + expect(totalSessions).toBeGreaterThan(0); + expect(totalDuration).toBeGreaterThan(0); + }); + + it.skip('BLOCKED: code 16 "session participant stats for call : not found" (no SFU participants without WebRTC): Pagination', async () => { + const call = client.video.call('default', attendanceCallId); + + // #region snippet docs="video/api/12-analytics/03-call-attendance.md" heading="Pagination" tab="Node.js" + // COPY: sessionId="" + let cursor; + do { + const response = await call.queryCallParticipantSessions({ + session: sessionId, + limit: 25, + next: cursor, + }); + cursor = response.next; + } while (cursor); + // #endregion snippet + + expect(cursor).toBeUndefined(); + }); +}); diff --git a/__tests__/docs-snippets/pages/video/api/12-analytics/04-participant-metrics.test.ts b/__tests__/docs-snippets/pages/video/api/12-analytics/04-participant-metrics.test.ts new file mode 100644 index 0000000..e8b3943 --- /dev/null +++ b/__tests__/docs-snippets/pages/video/api/12-analytics/04-participant-metrics.test.ts @@ -0,0 +1,123 @@ +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 { joinCallAsUser } from '../../../../helpers/video'; + +const DOCS = 'video/api/12-analytics/04-participant-metrics.md'; + +/** + * Participant metrics come from the SFU's stats. A REST join (`joinCallAsUser`) + * starts a call session but no SFU participant, so there are never metrics for it + * (code 16 "no stats available for this participant session", 2026-10-16). The + * fences run against a real joined session: the spy records that 404, which + * proves the request is accepted (a wrong session gives "call session not found", + * a time window over 30 minutes gives code 4 "time window cannot exceed 30 + * minutes"). + */ +describe(DOCS, () => { + const client = getServerClient(); + const cleanup = new Cleanup(client); + + const metricsUserId = uniqueId('user'); + const metricsCallId = uniqueId('call'); + const userSessionId = uniqueId('user-session'); + let sessionId = ''; + + /** Records the API's rejection of each `getCallParticipantSessionMetrics` call. */ + const recordRejections = () => { + const errors: unknown[] = []; + const original = client.video.getCallParticipantSessionMetrics.bind( + client.video, + ); + const spy = vi + .spyOn(client.video, 'getCallParticipantSessionMetrics') + .mockImplementation(async (request) => { + try { + return await original(request); + } catch (error) { + errors.push(error); + return undefined as never; + } + }); + return { errors, spy }; + }; + + const expectNoStats = (errors: unknown[]) => { + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 16, + metadata: { responseCode: 404 }, + }); + expect(String(errors[0])).toContain( + 'no stats available for this participant session', + ); + }; + + beforeAll(async () => { + cleanup.users.push(metricsUserId); + await client.upsertUsers([{ id: metricsUserId }]); + const setupCall = client.video.call('default', metricsCallId); + cleanup.calls.push(setupCall.cid); + await setupCall.create({ data: { created_by_id: metricsUserId } }); + sessionId = await joinCallAsUser(setupCall, metricsUserId); + }); + + afterEach(() => vi.restoreAllMocks()); + afterAll(() => cleanup.run()); + + it('Get participant session metrics', async () => { + const { errors, spy } = recordRejections(); + + // #region snippet docs="video/api/12-analytics/04-participant-metrics.md" heading="Response overview" tab="Node.js" + // COPY: metricsCallId="call-id", sessionId="", metricsUserId="", userSessionId="" + const call = client.video.call('default', metricsCallId); + const response = await call.getCallParticipantSessionMetrics({ + session: sessionId, + user: metricsUserId, + user_session: userSessionId, + }); + // #endregion snippet + + expect(response).toBeUndefined(); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'default', + id: metricsCallId, + session: sessionId, + user: metricsUserId, + user_session: userSessionId, + }); + expectNoStats(errors); + }); + + it('Time range', async () => { + const call = client.video.call('default', metricsCallId); + const { errors, spy } = recordRejections(); + + // #region snippet docs="video/api/12-analytics/04-participant-metrics.md" heading="Time range" tab="Node.js" + // COPY: sessionId="", metricsUserId="", userSessionId="" + const response = await call.getCallParticipantSessionMetrics({ + session: sessionId, + user: metricsUserId, + user_session: userSessionId, + since: new Date('2024-01-01T00:00:00Z'), + until: new Date('2024-01-01T00:30:00Z'), + }); + // #endregion snippet + + expect(response).toBeUndefined(); + expect(spy.mock.calls[0][0]).toMatchObject({ + since: new Date('2024-01-01T00:00:00Z'), + until: new Date('2024-01-01T00:30:00Z'), + }); + expectNoStats(errors); + }); +}); diff --git a/__tests__/docs-snippets/setup.ts b/__tests__/docs-snippets/setup.ts index 9b331ef..954f8b6 100644 --- a/__tests__/docs-snippets/setup.ts +++ b/__tests__/docs-snippets/setup.ts @@ -29,7 +29,7 @@ afterAll(async () => { if (mintedIds.size) { const filter = { matches: (id: string) => mintedIds.has(id) || id.includes(runId), - channelIds: [...mintedIds], + ids: [...mintedIds], }; let scan = await findLeftovers(serverClient, filter); if (scan.leftovers.length) { @@ -55,7 +55,7 @@ afterAll(async () => { 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` + + `Register them on \`Cleanup\` (cleanup.users / channels / channelTypes / calls / feeds / feedGroups, or cleanup.add(...)):\n` + describeLeftovers(scan.leftovers), ); } diff --git a/__tests__/docs-snippets/sync/docs-sync.test.ts b/__tests__/docs-snippets/sync/docs-sync.test.ts index ef58fd0..ec2050f 100644 --- a/__tests__/docs-snippets/sync/docs-sync.test.ts +++ b/__tests__/docs-snippets/sync/docs-sync.test.ts @@ -5,6 +5,7 @@ import { ALLOWED_JS_LABELS, applyCopy, DOCS_DIR, + EXCLUDED_FENCES, EXCLUDED_PAGES, fenceId, findSidebarPages, @@ -19,15 +20,15 @@ import { 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 and the platform sidebar has a test region. `JavaScript`, `React` and +// 1. Every `Node.js` fence (except comment-only fences) on the pages of the tested +// sidebars (`SIDEBAR_FILES` in parse.ts) has a test region. `JavaScript`, `React` and // `React Native` (client-side) fences, the stream-chat-js@9 fences // (`JavaScript (stream-chat-js@9)`, `Node.js (stream-chat-js@9)`), 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. +// Pages in `EXCLUDED_PAGES` and fences in `EXCLUDED_FENCES` (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. // chat/_default/04-messages/01-send_message.md; `1` = all pages) @@ -80,13 +81,14 @@ describe.skipIf(!hasDocs)(`docs sync (${DOCS_DIR})`, () => { fence.tab === NODE_TAB && !fence.commentOnly && !fence.noSdk && + !(fenceId(fence) in EXCLUDED_FENCES) && !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', + 'snippets without a test region; add one (in it.skip if it cannot run), or add the page to EXCLUDED_PAGES (or the fence to EXCLUDED_FENCES) in sync/parse.ts with a reason', ).toEqual([]); }); diff --git a/__tests__/docs-snippets/sync/parse.ts b/__tests__/docs-snippets/sync/parse.ts index 337cc6c..a7d8ed0 100644 --- a/__tests__/docs-snippets/sync/parse.ts +++ b/__tests__/docs-snippets/sync/parse.ts @@ -75,12 +75,25 @@ const NO_SDK_DISCLOSURE_RE = export const ALLOWED_JS_LABELS = [JS_TAB, NODE_TAB, ...OUT_OF_SCOPE_TABS]; /** - * Sidebars whose pages' `Node.js` fences are tested: the chat Node sidebar and the - * platform docs (shared by all SDKs). + * Sidebars whose pages' `Node.js` fences are tested: the chat, activity feeds (v3) and + * moderation Node sidebars, the video API (server-side) sidebar, and the platform docs + * (shared by all SDKs). */ -const SIDEBAR_FILES = ['[chat][node].json', '[platform][default].json']; +const SIDEBAR_FILES = [ + '[chat][node].json', + '[activity-feeds][node][v3].json', + '[moderation][node].json', + '[video][api].json', + '[platform][default].json', +]; /** Sidebar entries outside these docs are ignored. */ -const PAGE_PREFIXES = ['chat/', 'platform/']; +const PAGE_PREFIXES = [ + 'chat/', + 'activity-feeds/', + 'moderation/', + 'video/', + 'platform/', +]; /** * Sidebar pages that are deliberately not validated (page path relative to @@ -88,6 +101,23 @@ const PAGE_PREFIXES = ['chat/', 'platform/']; */ export const EXCLUDED_PAGES: Record = {}; +const FEEDS_V2_CLIENT = + 'Feeds v2 code for the `getstream` package (a different SDK), not @stream-io/node-sdk'; + +/** + * `Node.js` fences that are deliberately not tested (fence id as printed by `fenceId`, + * reason): code for another SDK that the docs label `Node.js`. They are not ours to + * update. Both checks skip them, and the label check accepts their label. + */ +export const EXCLUDED_FENCES: Record = { + 'moderation/_default/02-quick-start/03-quick-start-feeds-moderation.md > "Flag User" > Node.js #1': + FEEDS_V2_CLIENT, + 'moderation/_default/02-quick-start/03-quick-start-feeds-moderation.md > "Flag Activity" > Node.js #1': + FEEDS_V2_CLIENT, + 'moderation/_default/02-quick-start/03-quick-start-feeds-moderation.md > "Flag Reaction" > Node.js #1': + FEEDS_V2_CLIENT, +}; + const dedent = (lines: string[]) => { const indents = lines .filter((line) => line.trim()) @@ -121,6 +151,8 @@ export const parseDocsFences = ( const counts = new Map(); let heading = ''; let noSdk = false; + // Label of the enclosing `` (video docs), for fences without one. + let tabsItemLabel: string | undefined; for (let i = 0; i < lines.length; i++) { const open = /^(\s*)(`{3,})(\S*)(.*)$/.exec(lines[i]); if (!open) { @@ -128,6 +160,9 @@ export const parseDocsFences = ( 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; + const item = /^\s*]*\blabel="([^"]*)"/i.exec(lines[i]); + if (item) tabsItemLabel = item[1]; + else if (/^\s*<\/tabs-item>/i.test(lines[i])) tabsItemLabel = undefined; continue; } const [, indent, ticks, lang, rest] = open; @@ -137,7 +172,8 @@ export const parseDocsFences = ( !new RegExp(`^\\s*${ticks}\\s*$`).test(lines[end]) ) end++; - const tab = /label="([^"]*)"/.exec(rest)?.[1] ?? 'unlabelled'; + const tab = + /label="([^"]*)"/.exec(rest)?.[1] ?? tabsItemLabel ?? 'unlabelled'; if (JS_LANGS.has(lang.toLowerCase())) { const countKey = `${heading}\u0000${tab}`; const index = (counts.get(countKey) ?? 0) + 1; @@ -305,7 +341,12 @@ export const formatForDocs = async (code: string, markdownFile: string) => { } }; -type SidebarItem = { markdown?: string; children?: SidebarItem[] }; +type SidebarItem = { + markdown?: string; + children?: SidebarItem[]; + /** Name of a shared item (or item list) in `_sidebars/fragments/.json`. */ + $fragment?: string; +}; /** * Pages of the tested sidebars (paths relative to `content/docs`), minus @@ -318,6 +359,18 @@ export const findSidebarPages = () => { const page = entry.markdown; if (page && PAGE_PREFIXES.some((prefix) => page.startsWith(prefix))) pages.add(page); + if (entry.$fragment) { + const fragment = path.join( + DOCS_DIR, + '_sidebars', + 'fragments', + `${entry.$fragment}.json`, + ); + // A fragment is one item or a list of items. + const items = JSON.parse(fs.readFileSync(fragment, 'utf8')) as + SidebarItem | SidebarItem[]; + walk(Array.isArray(items) ? items : [items]); + } walk(entry.children); } }; diff --git a/src/gen/chat/ChannelApi.ts b/src/gen/chat/ChannelApi.ts index 8d793e3..1c4f16c 100644 --- a/src/gen/chat/ChannelApi.ts +++ b/src/gen/chat/ChannelApi.ts @@ -12,6 +12,8 @@ import { MarkReadRequest, MarkReadResponse, MarkUnreadRequest, + QueryChannelBansRequest, + QueryChannelBansResponse, Response, SendEventRequest, SendMessageRequest, @@ -114,6 +116,22 @@ export class ChannelApi { }); } + queryChannelBans( + request?: QueryChannelBansRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.queryChannelBans({ + id: this.id, + type: this.type, + ...request, + }); + } + deleteDraft(request?: { parent_id?: string; user_id?: string; diff --git a/src/gen/chat/ChatApi.ts b/src/gen/chat/ChatApi.ts index 0e4388d..ea7c71b 100644 --- a/src/gen/chat/ChatApi.ts +++ b/src/gen/chat/ChatApi.ts @@ -1,13 +1,11 @@ import { ApiClient, StreamResponse } from '../../gen-imports'; import { AddSegmentTargetsRequest, - CampaignResponse, CastPollVoteRequest, ChannelBatchUpdateRequest, ChannelBatchUpdateResponse, ChannelGetOrCreateRequest, ChannelStateResponse, - CommitMessageRequest, CreateCampaignRequest, CreateCampaignResponse, CreateChannelTypeRequest, @@ -30,6 +28,7 @@ import { DeleteReminderResponse, DeleteRetentionPolicyRequest, DeleteRetentionPolicyResponse, + DeleteScheduledMessageResponse, DeleteSegmentTargetsRequest, EventResponse, ExportChannelsRequest, @@ -71,6 +70,8 @@ import { QueryBannedUsersResponse, QueryCampaignsRequest, QueryCampaignsResponse, + QueryChannelBansRequest, + QueryChannelBansResponse, QueryChannelsRequest, QueryChannelsResponse, QueryDraftsRequest, @@ -87,6 +88,8 @@ import { QueryReactionsResponse, QueryRemindersRequest, QueryRemindersResponse, + QueryScheduledMessagesRequest, + QueryScheduledMessagesResponse, QuerySegmentTargetsRequest, QuerySegmentTargetsResponse, QuerySegmentsRequest, @@ -103,6 +106,7 @@ import { SendMessageResponse, SendReactionRequest, SendReactionResponse, + SendScheduledMessageNowResponse, SendUserCustomEventRequest, SetRetentionPolicyRequest, SetRetentionPolicyResponse, @@ -111,7 +115,7 @@ import { SortParamRequest, StartCampaignRequest, StartCampaignResponse, - StopCampaignRequest, + StopCampaignResponse, TranslateMessageRequest, TranslateMessageResponse, TruncateChannelRequest, @@ -123,6 +127,7 @@ import { UnreadCountsBatchRequest, UnreadCountsBatchResponse, UpdateCampaignRequest, + UpdateCampaignResponse, UpdateChannelPartialRequest, UpdateChannelPartialResponse, UpdateChannelRequest, @@ -141,6 +146,8 @@ import { UpdatePredefinedFilterResponse, UpdateReminderRequest, UpdateReminderResponse, + UpdateScheduledMessageRequest, + UpdateScheduledMessageResponse, UpdateSegmentRequest, UpdateSegmentResponse, UpdateThreadPartialRequest, @@ -262,7 +269,7 @@ export class ChatApi { async updateCampaign( request: UpdateCampaignRequest & { id: string }, - ): Promise> { + ): Promise> { const pathParams = { id: request?.id, }; @@ -283,7 +290,7 @@ export class ChatApi { channel_template: request?.channel_template, }; - const response = await this.apiClient.sendRequest( + const response = await this.apiClient.sendRequest( 'PUT', '/api/v2/chat/campaigns/{id}', pathParams, @@ -291,7 +298,7 @@ export class ChatApi { body, ); - decoders['CampaignResponse']?.(response); + decoders['UpdateCampaignResponse']?.(response); return response; } @@ -320,23 +327,21 @@ export class ChatApi { return response; } - async stopCampaign( - request: StopCampaignRequest & { id: string }, - ): Promise> { + async stopCampaign(request: { + id: string; + }): Promise> { const pathParams = { id: request?.id, }; - const body = {}; - const response = await this.apiClient.sendRequest( + const response = await this.apiClient.sendRequest( 'POST', '/api/v2/chat/campaigns/{id}/stop', pathParams, undefined, - body, ); - decoders['CampaignResponse']?.(response); + decoders['StopCampaignResponse']?.(response); return response; } @@ -345,6 +350,7 @@ export class ChatApi { request?: QueryChannelsRequest, ): Promise> { const body = { + include_active_member_count: request?.include_active_member_count, limit: request?.limit, member_limit: request?.member_limit, message_limit: request?.message_limit, @@ -452,6 +458,7 @@ export class ChatApi { const body = { limit: request?.limit, user_id: request?.user_id, + sort: request?.sort, groups: request?.groups, user: request?.user, }; @@ -661,6 +668,37 @@ export class ChatApi { return response; } + async queryChannelBans( + request: QueryChannelBansRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + exclude_expired_bans: request?.exclude_expired_bans, + include_total: request?.include_total, + limit: request?.limit, + next: request?.next, + prev: request?.prev, + user_id: request?.user_id, + filter_conditions: request?.filter_conditions, + user: request?.user, + }; + + const response = await this.apiClient.sendRequest( + 'POST', + '/api/v2/chat/channels/{type}/{id}/bans/query', + pathParams, + undefined, + body, + ); + + decoders['QueryChannelBansResponse']?.(response); + + return response; + } + async deleteDraft(request: { type: string; id: string; @@ -916,6 +954,7 @@ export class ChatApi { include_mentioned_members: request?.include_mentioned_members, keep_channel_hidden: request?.keep_channel_hidden, pending: request?.pending, + scheduled_at: request?.scheduled_at, skip_enrich_url: request?.skip_enrich_url, skip_push: request?.skip_push, pending_message_metadata: request?.pending_message_metadata, @@ -1191,8 +1230,10 @@ export class ChatApi { polls: request?.polls, push_level: request?.push_level, push_notifications: request?.push_notifications, + quotes: request?.quotes, reactions: request?.reactions, read_events: request?.read_events, + reminders: request?.reminders, replies: request?.replies, search: request?.search, shared_locations: request?.shared_locations, @@ -1205,6 +1246,7 @@ export class ChatApi { blocklists: request?.blocklists, commands: request?.commands, permissions: request?.permissions, + automod_thresholds: request?.automod_thresholds, chat_preferences: request?.chat_preferences, grants: request?.grants, }; @@ -1270,7 +1312,6 @@ export class ChatApi { const body = { automod: request?.automod, automod_behavior: request?.automod_behavior, - max_message_length: request?.max_message_length, blocklist: request?.blocklist, blocklist_behavior: request?.blocklist_behavior, connect_events: request?.connect_events, @@ -1278,6 +1319,7 @@ export class ChatApi { custom_events: request?.custom_events, delivery_events: request?.delivery_events, mark_messages_pending: request?.mark_messages_pending, + max_message_length: request?.max_message_length, message_retention: request?.message_retention, mutes: request?.mutes, partition_size: request?.partition_size, @@ -1646,20 +1688,18 @@ export class ChatApi { return response; } - async commitMessage( - request: CommitMessageRequest & { id: string }, - ): Promise> { + async commitMessage(request: { + id: string; + }): Promise> { const pathParams = { id: request?.id, }; - const body = {}; const response = await this.apiClient.sendRequest( 'POST', '/api/v2/chat/messages/{id}/commit', pathParams, undefined, - body, ); decoders['MessageActionResponse']?.(response); @@ -1854,6 +1894,8 @@ export class ChatApi { poll_id: request?.poll_id, }; const body = { + config_key: request?.config_key, + config_team: request?.config_team, user_id: request?.user_id, user: request?.user, vote: request?.vote, @@ -2339,6 +2381,97 @@ export class ChatApi { return response; } + async queryScheduledMessages( + request?: QueryScheduledMessagesRequest, + ): Promise> { + const body = { + limit: request?.limit, + next: request?.next, + user_id: request?.user_id, + sort: request?.sort, + filter: request?.filter, + user: request?.user, + }; + + const response = + await this.apiClient.sendRequest( + 'POST', + '/api/v2/chat/scheduled_messages/query', + undefined, + undefined, + body, + ); + + decoders['QueryScheduledMessagesResponse']?.(response); + + return response; + } + + async deleteScheduledMessage(request: { + id: string; + }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = + await this.apiClient.sendRequest( + 'DELETE', + '/api/v2/chat/scheduled_messages/{id}', + pathParams, + undefined, + ); + + decoders['DeleteScheduledMessageResponse']?.(response); + + return response; + } + + async updateScheduledMessage( + request: UpdateScheduledMessageRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + scheduled_at: request?.scheduled_at, + message: request?.message, + }; + + const response = + await this.apiClient.sendRequest( + 'PATCH', + '/api/v2/chat/scheduled_messages/{id}', + pathParams, + undefined, + body, + ); + + decoders['UpdateScheduledMessageResponse']?.(response); + + return response; + } + + async sendScheduledMessageNow(request: { + id: string; + }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = + await this.apiClient.sendRequest( + 'POST', + '/api/v2/chat/scheduled_messages/{id}/send_now', + pathParams, + undefined, + ); + + decoders['SendScheduledMessageNowResponse']?.(response); + + return response; + } + async search(request?: { payload?: SearchPayload; }): Promise> { diff --git a/src/gen/common/CommonApi.ts b/src/gen/common/CommonApi.ts index 0c9b712..557da90 100644 --- a/src/gen/common/CommonApi.ts +++ b/src/gen/common/CommonApi.ts @@ -1071,6 +1071,8 @@ export class CommonApi { name: request?.name, allow_answers: request?.allow_answers, allow_user_suggested_options: request?.allow_user_suggested_options, + config_key: request?.config_key, + config_team: request?.config_team, description: request?.description, enforce_unique_vote: request?.enforce_unique_vote, id: request?.id, @@ -1105,6 +1107,8 @@ export class CommonApi { name: request?.name, allow_answers: request?.allow_answers, allow_user_suggested_options: request?.allow_user_suggested_options, + config_key: request?.config_key, + config_team: request?.config_team, description: request?.description, enforce_unique_vote: request?.enforce_unique_vote, is_closed: request?.is_closed, @@ -1209,6 +1213,8 @@ export class CommonApi { poll_id: request?.poll_id, }; const body = { + config_key: request?.config_key, + config_team: request?.config_team, user_id: request?.user_id, unset: request?.unset, set: request?.set, @@ -1236,6 +1242,8 @@ export class CommonApi { }; const body = { text: request?.text, + config_key: request?.config_key, + config_team: request?.config_team, user_id: request?.user_id, custom: request?.custom, user: request?.user, @@ -1263,6 +1271,8 @@ export class CommonApi { const body = { id: request?.id, text: request?.text, + config_key: request?.config_key, + config_team: request?.config_team, user_id: request?.user_id, custom: request?.custom, user: request?.user, diff --git a/src/gen/feeds/FeedsApi.ts b/src/gen/feeds/FeedsApi.ts index 9ba7bdc..3bde870 100644 --- a/src/gen/feeds/FeedsApi.ts +++ b/src/gen/feeds/FeedsApi.ts @@ -209,6 +209,7 @@ export class FeedsApi { poll_id: request?.poll_id, restrict_replies: request?.restrict_replies, skip_enrich_url: request?.skip_enrich_url, + skip_existing: request?.skip_existing, skip_push: request?.skip_push, text: request?.text, user_id: request?.user_id, @@ -246,6 +247,7 @@ export class FeedsApi { create_users: request?.create_users, enrich_own_fields: request?.enrich_own_fields, force_moderation: request?.force_moderation, + skip_existing: request?.skip_existing, }; const response = await this.apiClient.sendRequest( @@ -511,6 +513,8 @@ export class FeedsApi { poll_id: request?.poll_id, }; const body = { + config_key: request?.config_key, + config_team: request?.config_team, user_id: request?.user_id, user: request?.user, vote: request?.vote, @@ -975,9 +979,11 @@ export class FeedsApi { async deleteCollections(request: { collection_refs: Array; + hard_delete?: boolean; }): Promise> { const queryParams = { collection_refs: request?.collection_refs, + hard_delete: request?.hard_delete, }; const response = @@ -2698,6 +2704,7 @@ export class FeedsApi { ): Promise> { const body = { follows: request?.follows, + async: request?.async, create_users: request?.create_users, enrich_own_fields: request?.enrich_own_fields, }; @@ -2720,6 +2727,7 @@ export class FeedsApi { ): Promise> { const body = { follows: request?.follows, + async: request?.async, create_users: request?.create_users, enrich_own_fields: request?.enrich_own_fields, }; @@ -2741,6 +2749,7 @@ export class FeedsApi { request?: QueryFollowsRequest, ): Promise> { const body = { + include_group_counts: request?.include_group_counts, limit: request?.limit, next: request?.next, prev: request?.prev, @@ -3078,6 +3087,7 @@ export class FeedsApi { ): Promise> { const body = { follows: request?.follows, + async: request?.async, delete_notification_activity: request?.delete_notification_activity, enrich_own_fields: request?.enrich_own_fields, }; @@ -3100,6 +3110,7 @@ export class FeedsApi { ): Promise> { const body = { follows: request?.follows, + async: request?.async, delete_notification_activity: request?.delete_notification_activity, enrich_own_fields: request?.enrich_own_fields, }; diff --git a/src/gen/model-decoders/decoders.ts b/src/gen/model-decoders/decoders.ts index 4272d08..7366104 100644 --- a/src/gen/model-decoders/decoders.ts +++ b/src/gen/model-decoders/decoders.ts @@ -1424,6 +1424,30 @@ decoders['ChangeFeedVisibilityResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['ChannelBanResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel_ban: { type: 'ChannelBanScopeResponse', isSingle: true }, + + future_channel_ban: { type: 'ChannelBanScopeResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelBanScopeResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + banned_by: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + decoders['ChannelBatchCompletedEvent'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { batch_created_at: { type: 'DatetimeType', isSingle: true }, @@ -1644,6 +1668,8 @@ decoders['ChannelStateResponse'] = (input?: { [key: string]: any }) => { read: { type: 'ReadStateResponse', isSingle: false }, + scheduled_messages: { type: 'ScheduledMessageResponse', isSingle: false }, + watchers: { type: 'UserResponse', isSingle: false }, channel: { type: 'ChannelResponse', isSingle: true }, @@ -1681,6 +1707,8 @@ decoders['ChannelStateResponseFields'] = (input?: { [key: string]: any }) => { read: { type: 'ReadStateResponse', isSingle: false }, + scheduled_messages: { type: 'ScheduledMessageResponse', isSingle: false }, + watchers: { type: 'UserResponse', isSingle: false }, channel: { type: 'ChannelResponse', isSingle: true }, @@ -2346,6 +2374,15 @@ decoders['DeleteReactionResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['DeleteScheduledMessageResponse'] = (input?: { + [key: string]: any; +}) => { + const typeMappings: TypeMapping = { + scheduled_message: { type: 'ScheduledMessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + decoders['DeviceResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { created_at: { type: 'DatetimeType', isSingle: true }, @@ -3360,15 +3397,6 @@ decoders['IngressStoppedEvent'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; -decoders['IntentDetectedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - decoders['KickedUserEvent'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { created_at: { type: 'DatetimeType', isSingle: true }, @@ -4260,6 +4288,28 @@ decoders['PolicyTestSetResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['PollClosedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + decoders['PollResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { poll: { type: 'PollResponseData', isSingle: true }, @@ -4282,6 +4332,56 @@ decoders['PollResponseData'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['PollUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteCastedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + poll_vote: { type: 'PollVoteResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteChangedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + poll_vote: { type: 'PollVoteResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteRemovedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + poll_vote: { type: 'PollVoteResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + decoders['PollVoteResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { poll: { type: 'PollResponseData', isSingle: true }, @@ -4564,6 +4664,13 @@ decoders['QueryCampaignsResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['QueryChannelBansResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + bans: { type: 'ChannelBanResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + decoders['QueryChannelsResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { channels: { type: 'ChannelStateResponseFields', isSingle: false }, @@ -4762,6 +4869,15 @@ decoders['QueryRevisionHistoryResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['QueryScheduledMessagesResponse'] = (input?: { + [key: string]: any; +}) => { + const typeMappings: TypeMapping = { + scheduled_messages: { type: 'ScheduledMessageResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + decoders['QuerySegmentTargetsResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { targets: { type: 'SegmentTargetResponse', isSingle: false }, @@ -5104,6 +5220,8 @@ decoders['ReviewQueueItemResponse'] = (input?: { [key: string]: any }) => { message: { type: 'ChatMessageResponse', isSingle: true }, + poll: { type: 'PollResponseData', isSingle: true }, + reaction: { type: 'Reaction', isSingle: true }, }; return decode(typeMappings, input); @@ -5199,6 +5317,65 @@ decoders['SIPTrunkResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['ScheduledMessageContentResponse'] = (input?: { + [key: string]: any; +}) => { + const typeMappings: TypeMapping = { + pin_expires: { type: 'DatetimeType', isSingle: true }, + + shared_location: { type: 'ScheduledMessageLocation', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ScheduledMessageFailedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + scheduled_message: { type: 'ScheduledMessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ScheduledMessageLocation'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + end_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ScheduledMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + scheduled_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'ScheduledMessageContentResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ScheduledMessageSentEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + scheduled_message: { type: 'ScheduledMessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + decoders['SearchResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { results: { type: 'SearchResult', isSingle: false }, @@ -5306,6 +5483,8 @@ decoders['SendMessageResponse'] = (input?: { [key: string]: any }) => { message: { type: 'MessageResponse', isSingle: true }, channel_context: { type: 'ChannelContextResponse', isSingle: true }, + + scheduled_message: { type: 'ScheduledMessageResponse', isSingle: true }, }; return decode(typeMappings, input); }; @@ -5319,6 +5498,15 @@ decoders['SendReactionResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['SendScheduledMessageNowResponse'] = (input?: { + [key: string]: any; +}) => { + const typeMappings: TypeMapping = { + scheduled_message: { type: 'ScheduledMessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + decoders['SessionWarningResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { time: { type: 'DatetimeType', isSingle: true }, @@ -5416,6 +5604,13 @@ decoders['StartCampaignResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['StopCampaignResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + campaign: { type: 'CampaignResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + decoders['StopLiveResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { call: { type: 'CallResponse', isSingle: true }, @@ -5717,6 +5912,13 @@ decoders['UpdateCallTypeResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['UpdateCampaignResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + campaign: { type: 'CampaignResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + decoders['UpdateChannelPartialResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { members: { type: 'ChannelMemberResponse', isSingle: false }, @@ -5885,6 +6087,15 @@ decoders['UpdateSIPTrunkResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['UpdateScheduledMessageResponse'] = (input?: { + [key: string]: any; +}) => { + const typeMappings: TypeMapping = { + scheduled_message: { type: 'ScheduledMessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + decoders['UpdateSegmentResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { segment: { type: 'SegmentResponse', isSingle: true }, diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index c077edc..16ddc68 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -93,6 +93,10 @@ export interface APIError { * Additional error-specific information */ details: Array; + /** + * Milliseconds until the user's slow mode cool down period ends. Only set on code 60 (cool down) errors + */ + cooldown_remaining_ms?: number; /** * Flag that indicates if the error is unrecoverable, requests that return unrecoverable errors should not be retried, this error only applies to the request that caused it */ @@ -1054,6 +1058,7 @@ export interface AddActivityRequest { * Whether to skip URL enrichment for the activity */ skip_enrich_url?: boolean; + skip_existing?: boolean; /** * Whether to skip push notifications */ @@ -1116,6 +1121,10 @@ export interface AddActivityResponse { * Number of mention notification activities created for mentioned users */ mention_notifications_created?: number; + /** + * Contains the activity ID when skip_existing is true and the activity already existed, so it was left unchanged. + */ + skipped_existing?: Array; } export interface AddBookmarkRequest { @@ -1566,6 +1575,14 @@ export interface AnalyzeResponse { * `complete` (all fields screened), `partial` (mix of verdicts and per-field errors), or `pending` (async). */ status: string; + /** + * ID of the LLM model that scored the content (e.g. gpt-4o-mini). Omitted when no LLM verdict was used (e.g. Bodyguard-only, or `pending`). + */ + llm_model?: string; + /** + * reasoning_effort sent with the LLM request. Omitted when the request did not send one. + */ + llm_reasoning_effort?: string; /** * Per-image moderation verdicts keyed by caller label. */ @@ -1603,6 +1620,17 @@ export interface AnalyzeTextField { classifications?: Array; } +export interface AppDefaults { + /** + * Defaults applied by the get-or-create channel endpoint. + */ + get_or_create_channel: GetOrCreateChannelDefaults; + /** + * Defaults applied by the query channels endpoint. + */ + query_channels: QueryChannelsDefaults; +} + export interface AppResponseFields { allow_multi_user_devices: boolean; /** @@ -1660,6 +1688,10 @@ export interface AppResponseFields { webhook_events: Array; call_types: Record; channel_configs: Record; + /** + * Request limit defaults applied by the server when the corresponding field is omitted. + */ + defaults: AppDefaults; file_upload_config: FileUploadConfig; grants: Record>; image_upload_config: FileUploadConfig; @@ -1915,6 +1947,7 @@ export interface AsyncExportUsersEvent { export interface AsyncImportErrorEvent { created_at: Date; error: string; + error_code: string; finished_at: Date; product: string; state: string; @@ -3777,6 +3810,7 @@ export interface CallSettingsRequest { geofencing?: GeofenceSettingsRequest; individual_recording?: IndividualRecordingSettingsRequest; ingress?: IngressSettingsRequest; + lifecycle?: LifecycleSettingsRequest; limits?: LimitsSettingsRequest; raw_recording?: RawRecordingSettingsRequest; recording?: RecordSettingsRequest; @@ -3802,6 +3836,10 @@ export interface CallSettingsResponse { frame_recording: FrameRecordingSettingsResponse; geofencing: GeofenceSettingsResponse; individual_recording: IndividualRecordingSettingsResponse; + /** + * LifecycleSettings is the payload for call lifecycle settings + */ + lifecycle: LifecycleSettingsResponse; limits: LimitsSettingsResponse; raw_recording: RawRecordingSettingsResponse; /** @@ -4218,8 +4256,6 @@ export interface CampaignStatsResponse { stats_users_sent: number; } -export interface CancelImportV2TaskRequest {} - export interface CancelImportV2TaskResponse { /** * Duration of the request in milliseconds @@ -4228,6 +4264,14 @@ export interface CancelImportV2TaskResponse { } export interface CastPollVoteRequest { + /** + * Key of the moderation configuration to use. Server-side only. + */ + config_key?: string; + /** + * Team associated with the configuration. Server-side only. + */ + config_team?: string; user_id?: string; /** * User request object @@ -4252,6 +4296,33 @@ export interface ChangeFeedVisibilityResponse { feed: FeedResponse; } +export interface ChannelBanResponse { + created_at: Date; + scopes: Array; + channel_ban?: ChannelBanScopeResponse; + future_channel_ban?: ChannelBanScopeResponse; + /** + * User response object + */ + user?: UserResponse; +} + +export interface ChannelBanScopeResponse { + created_at: Date; + shadow: boolean; + expires?: Date; + reason?: string; + /** + * User response object + */ + banned_by?: UserResponse; +} + +export interface ChannelBansScopeTotals { + channel: number; + future_channel: number; +} + export interface ChannelBatchCompletedEvent { batch_created_at: Date; created_at: Date; @@ -4808,6 +4879,7 @@ export interface ChannelMemberResponse { * Date/time of the last update */ updated_at: Date; + user_id: string; custom: Record; archived_at?: Date; /** @@ -4845,7 +4917,6 @@ export interface ChannelMemberResponse { */ role?: string; status?: string; - user_id?: string; deleted_messages?: Array; /** * User response object @@ -4946,6 +5017,7 @@ export const ChannelOwnCapability = { QUERY_POLL_VOTES: 'query-poll-votes', QUOTE_MESSAGE: 'quote-message', READ_EVENTS: 'read-events', + SCHEDULE_MESSAGE: 'schedule-message', SEARCH_MESSAGES: 'search-messages', SEND_CUSTOM_EVENTS: 'send-custom-events', SEND_LINKS: 'send-links', @@ -5013,6 +5085,10 @@ export interface ChannelResponse { * Custom data for this object */ custom: Record; + /** + * Number of members who are not deactivated or deleted + */ + active_member_count?: number; /** * Whether auto translation is enabled or not */ @@ -5094,16 +5170,54 @@ export interface ChannelResponse { export interface ChannelStateResponse { duration: string; + /** + * List of channel members + */ members: Array; + /** + * List of channel messages + */ messages: Array; + /** + * List of pinned messages in the channel + */ pinned_messages: Array; threads: Array; + /** + * Milliseconds until the current user's slow mode cool down period ends. Only set on client-side get or create channel and get channel requests while the user is under cool down + */ + cooldown_remaining_ms?: number; + /** + * Whether this channel is hidden or not + */ hidden?: boolean; + /** + * Messages before this date are hidden from the user + */ hide_messages_before?: Date; + /** + * Number of channel watchers + */ watcher_count?: number; + /** + * Active live locations in the channel + */ active_live_locations?: Array; + /** + * Pending messages that this user has sent + */ pending_messages?: Array; + /** + * List of read states + */ read?: Array; + /** + * The caller's own scheduled, sending and failed messages in this channel. Set only by a client-side GetOrCreateChannel or QueryChannels with include_scheduled_messages; absent when not requested or not known, in which case use the scheduled messages query + */ + scheduled_messages?: Array; + /** + * List of user who is watching the channel + */ watchers?: Array; /** * Represents channel in chat @@ -5131,6 +5245,10 @@ export interface ChannelStateResponseFields { */ pinned_messages: Array; threads: Array; + /** + * Milliseconds until the current user's slow mode cool down period ends. Only set on client-side get or create channel and get channel requests while the user is under cool down + */ + cooldown_remaining_ms?: number; /** * Whether this channel is hidden or not */ @@ -5155,6 +5273,10 @@ export interface ChannelStateResponseFields { * List of read states */ read?: Array; + /** + * The caller's own scheduled, sending and failed messages in this channel. Set only by a client-side GetOrCreateChannel or QueryChannels with include_scheduled_messages; absent when not requested or not known, in which case use the scheduled messages query + */ + scheduled_messages?: Array; /** * List of user who is watching the channel */ @@ -5625,14 +5747,20 @@ export interface CheckPushRequest { */ apn_template?: string; /** - * Type of event for push templates (default: message.new). One of: message.new, message.updated, reaction.new, reaction.updated, notification.reminder_due + * Type of event for push templates (default: message.new). One of: message.new, message.updated, reaction.new, reaction.updated, notification.reminder_due, feeds.activity.added, feeds.comment.added, feeds.activity.reaction.added, feeds.comment.reaction.added, feeds.follow.created, feeds.notification_feed.updated */ event_type?: | 'message.new' | 'message.updated' | 'reaction.new' | 'reaction.updated' - | 'notification.reminder_due'; + | 'notification.reminder_due' + | 'feeds.activity.added' + | 'feeds.comment.added' + | 'feeds.activity.reaction.added' + | 'feeds.comment.reaction.added' + | 'feeds.follow.created' + | 'feeds.notification_feed.updated'; /** * Push message data template for Firebase */ @@ -5744,25 +5872,21 @@ export interface CheckResponse { */ status: string; /** - * Intent topic label matched by this request's text (test mode only; omitted when no topic matched) - */ - intent_matched_topic?: string; - /** - * Stage 2 conversation score (0-100) computed synchronously against this request's texts (test mode only). Omitted -- not zero -- when intent_matched_topic is set but scoring could not be completed (matched topic disabled/removed, or the scoring call itself failed); only present when a real score was computed - */ - intent_score?: number; - /** - * The matched topic's configured score_threshold, for comparison against intent_score (test mode only). Same omitted-not-zero rule as intent_score + * ID of the LLM model that produced the verdict (e.g. gpt-4o-mini). Omitted when no LLM ran synchronously (e.g. Bodyguard-only checks or async moderation) */ - intent_score_threshold?: number; + llm_model?: string; /** - * Whether intent_score clears intent_score_threshold, i.e. whether this would fire moderation.intent_detected in production (test mode only). Same omitted-not-zero rule as intent_score + * reasoning_effort sent with the LLM request that produced the verdict. Omitted when the request did not send one */ - intent_would_fire?: boolean; + llm_reasoning_effort?: string; /** * ID of the running moderation task */ task_id?: string; + /** + * Score (0-100) of each policy intent topic against this request's text, in policy order (test mode only; omitted when the policy has no topics or scoring failed) + */ + intent_scores?: Array; /** * All moderation rules triggered by this check (content, user, and call rules), with their resolved actions */ @@ -5900,6 +6024,14 @@ export interface ClientEvent { * Reason the client initiated the join. Optional on CoordinatorJoin events; empty when not provided. */ join_reason?: string; + /** + * Source of the leave that aborted a join: user, app, callkit, remote_event, sdk_error, or unknown. Not validated against this set, so SDKs may report new values. Optional; sent on failure events. Maximum 32 characters. + */ + leave_initiator?: string; + /** + * Internal reason or triggering event for the leave that aborted a join. Optional; sent on failure events. Maximum 200 characters. + */ + leave_reason?: string; /** * Microphone permission status: INITIATED, FAILED, GRANTED, or NOT_INITIATED. Required on every MediaDevicePermission event. */ @@ -6386,8 +6518,6 @@ export interface CommentUpdatedEvent { user?: UserResponseCommonFields; } -export interface CommitMessageRequest {} - export interface CompositeRecordingResponse { status: string; } @@ -6756,7 +6886,7 @@ export interface CreateChannelTypeRequest { /** * Partition TTL */ - partition_ttl?: string; + partition_ttl?: string | null; /** * Polls */ @@ -6769,6 +6899,10 @@ export interface CreateChannelTypeRequest { * Push notifications */ push_notifications?: boolean; + /** + * Enables message quotes + */ + quotes?: boolean; /** * Reactions */ @@ -6777,6 +6911,10 @@ export interface CreateChannelTypeRequest { * Read events */ read_events?: boolean; + /** + * Enables message reminders + */ + reminders?: boolean; /** * Replies */ @@ -6815,11 +6953,15 @@ export interface CreateChannelTypeRequest { * List of permissions for the channel type */ permissions?: Array; + /** + * Sets thresholds for AI moderation + */ + automod_thresholds?: Thresholds; chat_preferences?: ChatPreferences; /** * List of grants for the channel type */ - grants?: Record>; + grants?: Record> | null; } export interface CreateChannelTypeResponse { @@ -7234,6 +7376,14 @@ export interface CreatePollOptionRequest { * Option text */ text: string; + /** + * Key of the moderation configuration to use. Server-side only. + */ + config_key?: string; + /** + * Team associated with the configuration. Server-side only. + */ + config_team?: string; user_id?: string; /** * Custom data for this object @@ -7255,6 +7405,14 @@ export interface CreatePollRequest { */ allow_answers?: boolean; allow_user_suggested_options?: boolean; + /** + * Key of the moderation configuration to use. Server-side only. + */ + config_key?: string; + /** + * Team associated with the configuration. Server-side only. + */ + config_team?: string; /** * A description of the poll */ @@ -8023,6 +8181,31 @@ export interface DeleteModerationTemplateResponse { duration: string; } +export interface DeletePollAnswerRequestPayload { + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeletePollOptionRequestPayload { + /** + * Option to delete. Required on an item for a whole poll (create or edit); defaults to the option an add_option / update_option item was raised for + */ + option_id?: string; + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeletePollRequestPayload { + /** + * Reason for deletion + */ + reason?: string; +} + export interface DeleteQueueRequest { user_id?: string; /** @@ -8095,6 +8278,17 @@ export interface DeleteSIPTrunkResponse { duration: string; } +export interface DeleteScheduledMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * A message scheduled to be sent later + */ + scheduled_message: ScheduledMessageResponse; +} + export interface DeleteSegmentTargetsRequest { /** * Target IDs @@ -8191,11 +8385,12 @@ export interface DeleteUsersRequest { /** * Conversation channels delete mode. * Conversation channel is any channel which only has two members one of which is the user being deleted. - * * null or empty string - doesn't delete any conversation channels + * * null or empty string - doesn't delete any conversation channels, unless user is 'hard', in which case it defaults to 'hard' * * soft - marks all conversation channels as deleted (same effect as Delete Channels with 'hard' option disabled) * * hard - deletes channel and all its data completely including messages (same effect as Delete Channels with 'hard' option enabled) + * * keep - doesn't delete any conversation channels. Use it with user 'hard', where null or empty string defaults to 'hard' */ - conversations?: 'soft' | 'hard'; + conversations?: 'soft' | 'hard' | 'keep'; /** * Delete user files. * * false or empty string - doesn't delete any files @@ -8216,7 +8411,7 @@ export interface DeleteUsersRequest { * User delete mode. * * soft - marks user as deleted and retains all user data * * pruning - marks user as deleted and nullifies user information - * * hard - deletes user completely. Requires 'hard' option for messages and conversations as well + * * hard - deletes user completely. Requires 'hard' option for messages, and 'hard' or 'keep' option for conversations */ user?: 'soft' | 'pruning' | 'hard'; } @@ -8455,8 +8650,6 @@ export interface EncryptionSettingsResponse { mode: 'available' | 'disabled' | 'auto-on'; } -export interface EndCallRequest {} - export interface EndCallResponse { /** * Duration of the request in milliseconds @@ -8468,6 +8661,7 @@ export interface EnrichedActivity { foreign_id?: string; id?: string; score?: number; + time?: string; verb?: string; to?: Array; actor?: Data; @@ -8514,15 +8708,15 @@ export interface EnrichedReaction { activity_id: string; kind: string; user_id: string; + created_at?: string; id?: string; parent?: string; + updated_at?: string; target_feeds?: Array; children_counts?: Record; - created_at?: Time; data?: Record; latest_children?: Record>; own_children?: Record>; - updated_at?: Time; user?: Data; } @@ -8535,6 +8729,10 @@ export interface EnrichmentOptions { * Controls the top-level flat 'activities' array for aggregated feeds. For new apps, defaults to false (excluded); set to true to include. For older apps, defaults to true (included) for backward compatibility; set to false to exclude. */ include_flat_activities?: boolean; + /** + * Default: false. Server-side only. When true, the response includes ranking_config: the ranking configuration (from the feed view, or the feed group as fallback) used to rank this read. + */ + include_ranking_config?: boolean; /** * Default: false. When true, includes score_vars in activity responses containing variable values used at ranking time. */ @@ -8796,8 +8994,6 @@ export interface ExportChannelsResponse { task_id: string; } -export interface ExportFeedUserDataRequest {} - export interface ExportFeedUserDataResponse { duration: string; /** @@ -9514,6 +9710,7 @@ export interface FeedsNotificationComment { comment: string; id: string; user_id: string; + parent_id?: string; attachments?: Array; } @@ -9978,6 +10175,10 @@ export interface FollowBatchRequest { * List of follow relationships to create */ follows: Array; + /** + * If true, the batch is queued as a background task, paced per app, and the response has empty follows and a task_id. Server-side only. Supported by the get-or-create endpoint (GetOrCreateFollows); FollowBatch rejects it. Async batches can apply out of order with other follow writes. Defaults to false. + */ + async?: boolean; /** * If true, auto-creates users referenced by source/target FIDs in the batch when they don't already exist. Server-side only. Defaults to false. This top-level field is the only supported batch/upsert create_users control. */ @@ -9998,6 +10199,10 @@ export interface FollowBatchResponse { * List of current follow relationships */ follows: Array; + /** + * ID of the background task when the request was sent with async: true + */ + task_id?: string; } export interface FollowCreatedEvent { @@ -10814,6 +11019,17 @@ export interface GetOrCreateCallResponse { call: CallResponse; } +export interface GetOrCreateChannelDefaults { + /** + * Resolved defaults for a nested pagination object. + */ + members: PagerDefaults; + /** + * Resolved defaults for a nested pagination object. + */ + messages: PagerDefaults; +} + export interface GetOrCreateFeedGroupRequest { /** * Role new followers of feeds in this group are given. Either a built-in (feed_follower, feed_member_viewer) or any role your app has defined. Empty means feed_follower. Applied when the follow is accepted, so a follow that starts pending picks it up on approval @@ -10913,6 +11129,7 @@ export interface GetOrCreateFeedResponse { following_pagination?: PagerResponse; member_pagination?: PagerResponse; notification_status?: NotificationStatusResponse; + ranking_config?: RankingConfig; } export interface GetOrCreateFeedViewRequest { @@ -11181,15 +11398,28 @@ export interface GroupedChannelsBucket { */ prev?: string; /** - * Unread channels currently classified into this bucket + * Total unread channels matching this bucket and its filters, independent of page size */ unread_channels?: number; } export interface GroupedChannelsGroupRequest { + /** + * Max channels for this group; omitted or zero uses the request limit + */ limit?: number; + /** + * Cursor for the next page; only allowed when requesting one group + */ next?: string; + /** + * Cursor for the previous page; only allowed when requesting one group + */ prev?: string; + /** + * Optional query_timestamp $gte bound for a stored group, requiring query_timestamp sort; do not repeat its group name. For all, combine group equality/$in and date bounds with $or, or use one standalone group $ne exclusion. An empty object matches a stored group; omitted or null preserves legacy grouping. + */ + filter_conditions?: Record; } export interface GroupedQueryChannelsRequest { @@ -11199,7 +11429,11 @@ export interface GroupedQueryChannelsRequest { limit?: number; user_id?: string; /** - * Groups to return, keyed by group name. Each group can define limit, next, or prev. 'next' and 'prev' cursors are only allowed when the request contains exactly one group; multi-group pagination is rejected. + * Optional query_timestamp descending with NULLS LAST when enabled. Omitted sort preserves existing last_updated ordering and cursor formats + */ + sort?: Array; + /** + * Groups to return, keyed by stored group name. Each entry can define limit, next, prev, and optional filter_conditions: date bounds for stored groups; all supports combined group/date branches or one group $ne exclusion. Cursors are only allowed when requesting exactly one group. Omitted or null filters preserve legacy grouping, including all. */ groups?: Record; /** @@ -11214,7 +11448,7 @@ export interface GroupedQueryChannelsResponse { */ duration: string; /** - * Predefined channel groups keyed by group name + * Groups keyed by the requested stored group name */ groups: Record; } @@ -11672,23 +11906,23 @@ export interface InsertActionLogResponse { duration: string; } -export interface IntentBufferedItem { +export interface IntentConditionInfo { /** - * The ID of the user who authored the buffered message or comment + * Whether the score reached the threshold. */ - author_user_id: string; + passed: boolean; /** - * When the buffered message or comment was created + * Final (damped) score of the topic in this analysis; 0 when the topic is not defined in the policy. */ - created_at: Date; + score: number; /** - * The ID of the buffered message or comment + * Minimum score (1-100) the condition requires. */ - id: string; + threshold: number; /** - * The text of the buffered message or comment + * Policy intent topic label the condition references. */ - text: string; + topic: string; } export interface IntentConfigRequest { @@ -11705,39 +11939,37 @@ export interface IntentConfigResponse { topics: Array; } -export interface IntentDetectedEvent { +export interface IntentEvaluationInfo { /** - * The channel CID (chat) or feed ID (feeds) the buffered conversation belongs to + * The analyzed conversation: the channel CID for chat, the object ID for feeds. */ conversation_id: string; - created_at: Date; /** - * The ID of the most recently buffered entity + * Number of conversation messages sent to the model. */ - entity_id: string; + message_count: number; /** - * The type of the most recently buffered entity (chat message or feed comment) + * One entry per `intent_rule` condition of the rule, flat conditions first, then group conditions. */ - entity_type: string; + conditions: Array; /** - * The configured intent topic label that started the buffer + * Score (0-100) the model gave every topic in the analysis call, keyed by topic label, before message-count damping. */ - matched_topic: string; + raw_scores: Record; /** - * The Stage 2 conversation score (0-100) that cleared score_threshold + * Final score (0-100) of every topic after message-count damping, keyed by topic label. Conditions compare against these. */ + scores: Record; +} + +export interface IntentRuleParameters { + topic: string; + threshold?: number; +} + +export interface IntentScoreResponse { score: number; - /** - * The ID of the user whose conversation was scored - */ - user_id: string; - /** - * The buffered items that were scored, in the order they were captured - */ - items: Array; - custom: Record; - type: string; - received_at?: Date; + topic: string; } export interface IntentTopicRequest { @@ -11745,61 +11977,21 @@ export interface IntentTopicRequest { * Unique topic label (e.g. purchase_intent) */ label: string; - /** - * How long the buffer collects items before scoring runs - */ - analysis_cooldown_seconds?: number; /** * Optional description used in the classification prompt; the label alone is used when empty */ description?: string; - /** - * Whether this topic is evaluated - */ - enabled?: boolean; - /** - * Items buffered per conversation before scoring runs early - */ - max_captured_items?: number; - /** - * Suppresses this topic for a user and conversation after it fires; 0 uses the default - */ - refire_cooldown_seconds?: number; - /** - * Conversation score (0-100) required to fire moderation.intent_detected - */ - score_threshold?: number; } export interface IntentTopicResponse { - /** - * How long the buffer collects items before scoring runs - */ - analysis_cooldown_seconds: number; - /** - * Whether this topic is evaluated - */ - enabled: boolean; /** * Unique topic label (e.g. purchase_intent) */ label: string; - /** - * Items buffered per conversation before scoring runs early - */ - max_captured_items: number; - /** - * Conversation score (0-100) required to fire moderation.intent_detected - */ - score_threshold: number; /** * Optional description used in the classification prompt; the label alone is used when empty */ description?: string; - /** - * Suppresses this topic for a user and conversation after it fires; 0 uses the default - */ - refire_cooldown_seconds?: number; } export interface InterestTagResponse { @@ -11894,7 +12086,9 @@ export interface KickedUserEvent { export interface LLMConfig { app_context?: string; async?: boolean; + custom_keys_context_description?: string; enabled?: boolean; + context_custom_keys?: Array; rules?: Array; severity_descriptions?: Record; } @@ -12086,6 +12280,20 @@ export interface LayoutSettingsResponse { options?: Record; } +export interface LifecycleSettingsRequest { + /** + * Call lifecycle mode. One of: reusable, single_use. A single_use call cannot be reused once it has ended; the server ends it when its ring settles without an answer (currently: when the caller cancels the ring). Fixed at call creation + */ + mode?: 'reusable' | 'single_use'; +} + +export interface LifecycleSettingsResponse { + /** + * Call lifecycle mode. One of: reusable, single_use. A single_use call cannot be reused once it has ended; the server ends it when its ring settles without an answer (currently: when the caller cancels the ring). Fixed at call creation + */ + mode: 'reusable' | 'single_use'; +} + export interface LimitInfoResponse { /** * The maximum number of API calls allowed per time window @@ -13121,7 +13329,7 @@ export interface MessageRequest { /** * Date when pinned message expires */ - pin_expires?: Date; + pin_expires?: Date | null; /** * Whether message is pinned or not */ @@ -13129,7 +13337,7 @@ export interface MessageRequest { /** * Date when message got pinned */ - pinned_at?: Date; + pinned_at?: Date | null; /** * Identifier of the poll to include in the message */ @@ -14109,6 +14317,7 @@ export interface ModerationRulesTriggeredEvent { * Ordered list of contents whose verdicts contributed to an aggregation rule's threshold. Populated only for aggregation rules when callers supplied `content_ids`. */ matched_contents?: Array; + intent?: IntentEvaluationInfo; } export interface ModerationTextAnalysisCompleteEvent { @@ -14244,9 +14453,25 @@ export interface NoiseCancellationSettings { } export interface NotificationComment { + /** + * The text of the comment + */ comment: string; + /** + * The ID of the comment + */ id: string; + /** + * The ID of the user who wrote the comment + */ user_id: string; + /** + * The ID of the parent comment when this comment is a reply; omitted for top-level comments + */ + parent_id?: string; + /** + * Attachments on the comment + */ attachments?: Array; } @@ -14645,6 +14870,7 @@ export interface OwnUserResponse { unread_count: number; unread_threads: number; updated_at: Date; + blocked_user_ids: Array; channel_mutes: Array; devices: Array; mutes: Array; @@ -14657,7 +14883,6 @@ export interface OwnUserResponse { last_active?: Date; name?: string; revoke_tokens_issued_before?: Date; - blocked_user_ids?: Array; latest_hidden_channels?: Array; /** * The privacy settings of the user. @@ -14671,10 +14896,17 @@ export interface OwnUserResponse { total_unread_count_by_team?: Record; } -export interface PagerRequest { - limit?: number; - next?: string; - prev?: string; +export interface PagerDefaults { + /** + * Number of records returned when `limit` is omitted. + */ + limit: number; +} + +export interface PagerRequest { + limit?: number; + next?: string; + prev?: string; } export interface PagerResponse { @@ -15151,6 +15383,52 @@ export interface PolicyTestTotals { unscored: number; } +export interface PollClosedEvent { + /** + * Date/time of creation + */ + created_at: Date; + custom: Record; + poll: PollResponseData; + /** + * The type of event: "poll.closed" in this case + */ + type: string; + activity_id?: string; + /** + * The CID of the channel containing the poll + */ + cid?: string; + /** + * The ID of the message containing the poll + */ + message_id?: string; + received_at?: Date; +} + +export interface PollDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + custom: Record; + poll: PollResponseData; + /** + * The type of event: "poll.deleted" in this case + */ + type: string; + activity_id?: string; + /** + * The CID of the channel containing the poll + */ + cid?: string; + /** + * The ID of the message containing the poll + */ + message_id?: string; + received_at?: Date; +} + export interface PollOptionInput { text?: string; custom?: Record; @@ -15217,6 +15495,101 @@ export interface PollResponseData { name_i18n?: Record; } +export interface PollUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + custom: Record; + poll: PollResponseData; + /** + * The type of event: "poll.updated" in this case + */ + type: string; + activity_id?: string; + /** + * The CID of the channel containing the poll + */ + cid?: string; + /** + * The ID of the message containing the poll + */ + message_id?: string; + received_at?: Date; +} + +export interface PollVoteCastedEvent { + /** + * Date/time of creation + */ + created_at: Date; + custom: Record; + poll: PollResponseData; + poll_vote: PollVoteResponseData; + /** + * The type of event: "poll.vote_casted" in this case + */ + type: string; + activity_id?: string; + /** + * The CID of the channel containing the poll + */ + cid?: string; + /** + * The ID of the message containing the poll + */ + message_id?: string; + received_at?: Date; +} + +export interface PollVoteChangedEvent { + /** + * Date/time of creation + */ + created_at: Date; + custom: Record; + poll: PollResponseData; + poll_vote: PollVoteResponseData; + /** + * The type of event: "poll.vote_changed" in this case + */ + type: string; + activity_id?: string; + /** + * The CID of the channel containing the poll + */ + cid?: string; + /** + * The ID of the message containing the poll + */ + message_id?: string; + received_at?: Date; +} + +export interface PollVoteRemovedEvent { + /** + * Date/time of creation + */ + created_at: Date; + custom: Record; + poll: PollResponseData; + poll_vote: PollVoteResponseData; + /** + * The type of event: "poll.vote_removed" in this case + */ + type: string; + activity_id?: string; + /** + * The CID of the channel containing the poll + */ + cid?: string; + /** + * The ID of the message containing the poll + */ + message_id?: string; + received_at?: Date; +} + export interface PollVoteResponse { /** * Duration of the request in milliseconds @@ -15428,12 +15801,20 @@ export interface PushPreferenceInput { * Remove the disabled until time. (IE stop snoozing notifications) */ remove_disable?: boolean; + /** + * Remove the user's quiet-hours schedule, the counterpart of remove_disable. It is applied before schedule, so sending both in one item replaces the stored schedule instead of merging into it. Cannot be combined with channel_cid. To switch from a schedule to a snooze, send remove_schedule together with disabled_until. + */ + remove_schedule?: boolean; /** * The user id for which to set the push preferences. Required when using server side auths, defaults to current user with client side auth. */ user_id?: string; chat_preferences?: ChatPreferencesInput; feeds_preferences?: FeedsPreferences; + /** + * A recurring quiet-hours schedule: while any quiet window is active, push notifications for the user are suppressed. Suppressed notifications are dropped, not delayed. + */ + schedule?: PushScheduleInput; } export interface PushPreferencesResponse { @@ -15449,6 +15830,10 @@ export interface PushPreferencesResponse { feeds_level?: string; chat_preferences?: ChatPreferencesResponse; feeds_preferences?: FeedsPreferencesResponse; + /** + * A recurring quiet-hours schedule: while any quiet window is active, push notifications for the user are suppressed. Suppressed notifications are dropped, not delayed. + */ + schedule?: PushScheduleResponse; } export interface PushProvider { @@ -15540,6 +15925,28 @@ export interface PushProviderResponse { xiaomi_package_name?: string; } +export interface PushScheduleInput { + /** + * IANA time zone name the quiet windows are evaluated in, for example Europe/Amsterdam or America/New_York. Daylight saving time is followed automatically for region-based names. Offsets such as +02:00 and the name "Local" are rejected; fixed-offset zones such as UTC or Etc/GMT-2 are accepted but never shift for daylight saving time. When updating an existing schedule, a non-empty timezone replaces the stored one and leaves the stored quiet_hours untouched. + */ + timezone?: string; + /** + * The quiet windows, 1 to 7 rules. Push is suppressed while any rule is active. When present it replaces all stored rules; omit it to keep them, for example to change only the timezone. An empty array counts as omitted, so it cannot be used to clear the rules: use remove_schedule for that. + */ + quiet_hours?: Array; +} + +export interface PushScheduleResponse { + /** + * IANA time zone name the quiet windows are evaluated in, for example Europe/Amsterdam. + */ + timezone: string; + /** + * The quiet windows. Push is suppressed while any rule is active. + */ + quiet_hours: Array; +} + export interface PushTemplate { created_at: Date; enable_push: boolean; @@ -16094,7 +16501,71 @@ export interface QueryCampaignsResponse { prev?: string; } +export interface QueryChannelBansRequest { + /** + * Whether to exclude expired bans or not + */ + exclude_expired_bans?: boolean; + /** + * When true, the response includes total and total_by_scope: the number of banned users overall and per scope, independent of paging + */ + include_total?: boolean; + limit?: number; + next?: string; + prev?: string; + user_id?: string; + /** + * Filter conditions to apply to the query + */ + filter_conditions?: Record; + /** + * User request object + */ + user?: UserRequest; +} + +export interface QueryChannelBansResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * Users banned from the channel, one entry per user, newest ban first + */ + bans: Array; + next?: string; + prev?: string; + /** + * Number of banned users, each counted once, ignoring paging. Counted per scope with a cap of 100000 each, so it can reach 200000. Only present when include_total is set on the request; omitted when counting timed out + */ + total?: number; + total_by_scope?: ChannelBansScopeTotals; +} + +export interface QueryChannelsDefaults { + /** + * Number of channels returned when `limit` is omitted. + */ + limit: number; + /** + * Largest number of channels returned for one request. A bigger `limit` is silently clamped to this value rather than rejected. An app with no per-app override gets a higher ceiling for the narrow case of a request asking for at most one message and one member per channel. + */ + max_limit: number; + /** + * Number of members returned per channel when `member_limit` is omitted. + */ + member_limit: number; + /** + * Number of messages returned per channel when `message_limit` is omitted. + */ + message_limit: number; +} + export interface QueryChannelsRequest { + /** + * Whether to include active_member_count for each channel + */ + include_active_member_count?: boolean; /** * Number of channels to limit */ @@ -16402,6 +16873,10 @@ export interface QueryFeedsUsageStatsResponse { } export interface QueryFollowsRequest { + /** + * When true (server-side only), the response includes group_counts: accepted following counts keyed by the followed feed's group. Requires exactly one source_feed / source_fid string equality. HTTP 400 if target_feed appears, if two source FIDs are given, or if $in / $or / $nor mentions those keys. Independent of paging and of other filters, including status. + */ + include_group_counts?: boolean; limit?: number; next?: string; prev?: string; @@ -16429,6 +16904,10 @@ export interface QueryFollowsResponse { * Cursor for previous page */ prev?: string; + /** + * Accepted following counts keyed by followed feed group. Present only when include_group_counts is set with a source_feed equality. + */ + group_counts?: Record; } export interface QueryFutureChannelBansPayload { @@ -17009,6 +17488,45 @@ export interface QueryRevisionHistoryResponse { prev?: string; } +export interface QueryScheduledMessagesRequest { + /** + * Page size, 1 to 100. Defaults to 20 + */ + limit?: number; + /** + * Cursor from the previous page + */ + next?: string; + user_id?: string; + /** + * Only scheduled_at ascending is supported. Pending messages come before failed ones + */ + sort?: Array; + /** + * Filter for querying scheduled messages + */ + filter?: ScheduledMessagesFilter; + /** + * User request object + */ + user?: UserRequest; +} + +export interface QueryScheduledMessagesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * Pending messages first, then failed ones, each by scheduled_at + */ + scheduled_messages: Array; + /** + * Cursor for the next page; empty on the last page + */ + next?: string; +} + export interface QuerySegmentTargetsRequest { /** * Limit @@ -17216,6 +17734,36 @@ export interface QueueResponse { queue?: ModerationQueueResponse; } +export interface QuietHoursRuleInput { + /** + * When the window closes, as HH:MM local wall-clock time between 00:00 and 24:00. The end is exclusive: a window ending at 07:00 is already over at 07:00. An end earlier than start wraps past midnight into the next day (start 22:00, end 07:00). 24:00 means the end of the day, so start 00:00 with end 24:00 covers the whole day. + */ + end: string; + /** + * When the window opens, as HH:MM local wall-clock time between 00:00 and 23:59. Must differ from end. + */ + start: string; + /** + * The days of the week the window opens on, any of mon, tue, wed, thu, fri, sat, sun, each at most once. A window belongs to the day it opens: ["fri"] with 22:00 to 07:00 is quiet from Friday 22:00 until Saturday 07:00, and not on Saturday night. Empty or omitted means every day. + */ + days?: Array; +} + +export interface QuietHoursRuleResponse { + /** + * When the window closes, as HH:MM local wall-clock time between 00:00 and 24:00. The end is exclusive. An end earlier than start wraps past midnight into the next day. + */ + end: string; + /** + * When the window opens, as HH:MM local wall-clock time between 00:00 and 23:59. + */ + start: string; + /** + * The days of the week the window opens on, any of mon, tue, wed, thu, fri, sat, sun. A window belongs to the day it opens: ["fri"] with 22:00 to 07:00 is quiet from Friday 22:00 until Saturday 07:00. Omitted means every day. + */ + days?: Array; +} + export interface RTMPBroadcastRequest { /** * Name identifier for RTMP broadcast, must be unique in call @@ -18089,8 +18637,6 @@ export interface RestoreCommentResponse { comment: CommentResponse; } -export interface RestoreFeedGroupRequest {} - export interface RestoreFeedGroupResponse { duration: string; feed_group: FeedGroupResponse; @@ -18238,6 +18784,7 @@ export interface ReviewQueueItemResponse { * Content payload for moderation */ moderation_payload?: ModerationPayloadResponse; + poll?: PollResponseData; reaction?: Reaction; } @@ -18504,6 +19051,10 @@ export interface RuleBuilderCondition { flood_similar_params?: FloodSimilarRuleParameters; image_content_params?: ImageContentParameters; image_rule_params?: ImageRuleParameters; + /** + * intent_rule params: policy intent topic label and the minimum final score (1-100) that fires the condition + */ + intent_rule_params?: IntentRuleParameters; ip_content_count_rule_params?: IPContentCountRuleParameters; ip_flag_count_rule_params?: IPFlagCountRuleParameters; keyframe_ocr_rule_params?: KeyframeOCRRuleParameters; @@ -18511,6 +19062,7 @@ export interface RuleBuilderCondition { ocr_content_params?: OCRContentParameters; text_content_params?: TextContentParameters; text_rule_params?: TextRuleParameters; + user_channel_ban_count_params?: UserChannelBanCountRuleParameters; user_channel_count_params?: UserChannelCountRuleParameters; user_created_within_params?: UserCreatedWithinParameters; user_custom_property_params?: UserCustomPropertyParameters; @@ -18775,164 +19327,386 @@ export interface SIPInboundRoutingRuleRequest { /** * Configuration for SIP caller settings */ - caller_configs: SIPCallerConfigsRequest; + caller_configs: SIPCallerConfigsRequest; + /** + * List of called numbers + */ + called_numbers?: Array; + /** + * List of caller numbers (optional) + */ + caller_numbers?: Array; + /** + * Configuration for SIP call settings + */ + call_configs?: SIPCallConfigsRequest; + /** + * Configuration for direct routing rule calls + */ + direct_routing_configs?: SIPDirectRoutingRuleCallConfigsRequest; + /** + * Configuration for PIN protection settings + */ + pin_protection_configs?: SIPPinProtectionConfigsRequest; + /** + * Configuration for PIN routing rule calls + */ + pin_routing_configs?: SIPInboundRoutingRulePinConfigsRequest; +} + +export interface SIPInboundRoutingRuleResponse { + /** + * Creation timestamp + */ + created_at: Date; + duration: string; + /** + * Unique identifier of the SIP Inbound Routing Rule + */ + id: string; + /** + * Name of the SIP Inbound Routing Rule + */ + name: string; + /** + * Last update timestamp + */ + updated_at: Date; + /** + * List of called numbers + */ + called_numbers: Array; + /** + * List of SIP trunk IDs + */ + trunk_ids: Array; + /** + * List of caller numbers + */ + caller_numbers?: Array; + /** + * SIP call configuration response + */ + call_configs?: SIPCallConfigsResponse; + /** + * SIP caller configuration response + */ + caller_configs?: SIPCallerConfigsResponse; + /** + * Direct routing rule call configuration response + */ + direct_routing_configs?: SIPDirectRoutingRuleCallConfigsResponse; + /** + * PIN protection configuration response + */ + pin_protection_configs?: SIPPinProtectionConfigsResponse; + /** + * PIN routing rule call configuration response + */ + pin_routing_configs?: SIPInboundRoutingRulePinConfigsResponse; +} + +export interface SIPPinProtectionConfigsRequest { + /** + * Default PIN to use if there is no PIN set on the call object + */ + default_pin?: string; + /** + * Whether PIN protection is enabled + */ + enabled?: boolean; + /** + * Maximum number of PIN attempts allowed + */ + max_attempts?: number; + /** + * Number of digits required for the PIN + */ + required_pin_digits?: number; +} + +export interface SIPPinProtectionConfigsResponse { + /** + * Whether PIN protection is enabled + */ + enabled: boolean; + /** + * Default PIN to use if there is no PIN set on the call object + */ + default_pin?: string; + /** + * Maximum number of PIN attempts allowed + */ + max_attempts?: number; + /** + * Number of digits required for the PIN + */ + required_pin_digits?: number; +} + +export interface SIPTrunkResponse { + /** + * Creation timestamp + */ + created_at: Date; + /** + * Unique identifier for the SIP trunk + */ + id: string; + /** + * Name of the SIP trunk + */ + name: string; + /** + * Password for SIP trunk authentication + */ + password: string; + /** + * Last update timestamp + */ + updated_at: Date; + /** + * The URI for the SIP trunk + */ + uri: string; + /** + * Username for SIP trunk authentication + */ + username: string; + /** + * Allowed IPv4/IPv6 addresses or CIDR blocks + */ + allowed_ips: Array; + /** + * Phone numbers associated with this SIP trunk + */ + numbers: Array; +} + +export interface SRTIngress { + address: string; +} + +export interface ScheduledMessageChannelResponse { + /** + * Channel CID (:) + */ + cid: string; + /** + * Channel ID + */ + id: string; + /** + * Channel type + */ + type: string; + /** + * Channel image, from its custom data + */ + image?: string; + /** + * Channel name, from its custom data + */ + name?: string; +} + +export interface ScheduledMessageContentResponse { /** - * List of called numbers + * Text of the message */ - called_numbers?: Array; + text: string; + custom: Record; /** - * List of caller numbers (optional) + * Contains HTML markup of the message */ - caller_numbers?: Array; + html?: string; /** - * Configuration for SIP call settings + * Whether the message mentions the whole channel */ - call_configs?: SIPCallConfigsRequest; + mentioned_channel?: boolean; /** - * Configuration for direct routing rule calls + * Whether the message mentions the online channel members */ - direct_routing_configs?: SIPDirectRoutingRuleCallConfigsRequest; + mentioned_here?: boolean; /** - * Configuration for PIN protection settings + * MML content of the message */ - pin_protection_configs?: SIPPinProtectionConfigsRequest; + mml?: string; /** - * Configuration for PIN routing rule calls + * ID of parent message (thread) */ - pin_routing_configs?: SIPInboundRoutingRulePinConfigsRequest; -} - -export interface SIPInboundRoutingRuleResponse { + parent_id?: string; /** - * Creation timestamp + * Date/time the pin expires */ - created_at: Date; - duration: string; + pin_expires?: Date; /** - * Unique identifier of the SIP Inbound Routing Rule + * Whether the message will be pinned when sent */ - id: string; + pinned?: boolean; /** - * Name of the SIP Inbound Routing Rule + * Identifier of the poll to include in the message */ - name: string; + poll_id?: string; + quoted_message_id?: string; /** - * Last update timestamp + * Whether thread reply should be shown in the channel as well */ - updated_at: Date; + show_in_channel?: boolean; /** - * List of called numbers + * Whether message is silent or not */ - called_numbers: Array; + silent?: boolean; /** - * List of SIP trunk IDs + * Contains type of the message. One of: regular, system */ - trunk_ids: Array; + type?: string; /** - * List of caller numbers + * Array of message attachments */ - caller_numbers?: Array; + attachments?: Array; /** - * SIP call configuration response + * IDs of the user groups mentioned in the message */ - call_configs?: SIPCallConfigsResponse; + mentioned_group_ids?: Array; /** - * SIP caller configuration response + * Roles mentioned in the message */ - caller_configs?: SIPCallerConfigsResponse; + mentioned_roles?: Array; /** - * Direct routing rule call configuration response + * IDs of the users mentioned in the message */ - direct_routing_configs?: SIPDirectRoutingRuleCallConfigsResponse; + mentioned_users?: Array; /** - * PIN protection configuration response + * IDs of the users the message will be visible to */ - pin_protection_configs?: SIPPinProtectionConfigsResponse; + restricted_visibility?: Array; /** - * PIN routing rule call configuration response + * The location a scheduled message will share */ - pin_routing_configs?: SIPInboundRoutingRulePinConfigsResponse; + shared_location?: ScheduledMessageLocation; } -export interface SIPPinProtectionConfigsRequest { +export interface ScheduledMessageFailedEvent { /** - * Default PIN to use if there is no PIN set on the call object + * Date/time of creation */ - default_pin?: string; + created_at: Date; + custom: Record; + user: UserResponseCommonFields; /** - * Whether PIN protection is enabled + * The type of event: "scheduled_message.failed" in this case */ - enabled?: boolean; + type: string; /** - * Maximum number of PIN attempts allowed + * The CID of the channel the message is scheduled in */ - max_attempts?: number; + cid?: string; + received_at?: Date; /** - * Number of digits required for the PIN + * A message scheduled to be sent later */ - required_pin_digits?: number; + scheduled_message?: ScheduledMessageResponse; } -export interface SIPPinProtectionConfigsResponse { +export interface ScheduledMessageLocation { /** - * Whether PIN protection is enabled + * The ID of the device that created the location. */ - enabled: boolean; + created_by_device_id: string; /** - * Default PIN to use if there is no PIN set on the call object + * The date when the location sharing ends. + * If it's empty, it means the location sharing is static instead of live. */ - default_pin?: string; + end_at?: Date; /** - * Maximum number of PIN attempts allowed + * The latitude of the location. */ - max_attempts?: number; + latitude?: number; /** - * Number of digits required for the PIN + * The longitude of the location. */ - required_pin_digits?: number; + longitude?: number; } -export interface SIPTrunkResponse { +export interface ScheduledMessageResponse { /** - * Creation timestamp + * The CID of the channel the message will be sent to + */ + cid: string; + /** + * Date/time of creation */ created_at: Date; /** - * Unique identifier for the SIP trunk + * Unique identifier of the scheduled message */ id: string; /** - * Name of the SIP trunk + * Date/time the message is scheduled to be sent */ - name: string; + scheduled_at: Date; /** - * Password for SIP trunk authentication + * Status of the scheduled message. One of: scheduled, sending, failed, canceled, sent. A message is sending from its first send attempt until it is sent or fails, including while it waits to retry a failed attempt; it cannot be edited, canceled or sent now while sending */ - password: string; + status: string; /** - * Last update timestamp + * Date/time of the last update */ updated_at: Date; /** - * The URI for the SIP trunk + * The message a scheduled message will send */ - uri: string; + message: ScheduledMessageContentResponse; /** - * Username for SIP trunk authentication + * User response object */ - username: string; + user: UserResponse; /** - * Allowed IPv4/IPv6 addresses or CIDR blocks + * Why sending the message failed. Only set when status is failed. One of: banned, no_permission, channel_frozen, channel_deleted, user_deleted, moderation_blocked, hook_rejected, expired, internal_error, invalid_message, pending_not_supported */ - allowed_ips: Array; + failure_reason?: string; /** - * Phone numbers associated with this SIP trunk + * The channel of a scheduled message */ - numbers: Array; + channel?: ScheduledMessageChannelResponse; } -export interface SRTIngress { - address: string; +export interface ScheduledMessageSentEvent { + /** + * Date/time of creation + */ + created_at: Date; + /** + * The ID of the message that was sent + */ + message_id: string; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.sent" in this case + */ + type: string; + /** + * The CID of the channel the message was sent to + */ + cid?: string; + received_at?: Date; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessagesFilter { + /** + * Only scheduled messages of this channel + */ + cid?: string; + /** + * Only these statuses, as reported in each scheduled message's status. One or more of: scheduled, sending, failed + */ + status?: Array; } export interface ScoreBands { @@ -19218,6 +19992,10 @@ export interface SendMessageRequest { include_mentioned_members?: boolean; keep_channel_hidden?: boolean; pending?: boolean; + /** + * When set, the message is not sent now: it is stored and sent at this time (UTC). The response then carries scheduled_message. Must be at least 1 minute and at most the app's maximum lead time (120 days by default) from now. Slash commands cannot be scheduled + */ + scheduled_at?: Date; skip_enrich_url?: boolean; skip_push?: boolean; pending_message_metadata?: Record; @@ -19244,6 +20022,10 @@ export interface SendMessageResponse { * Pending message metadata */ pending_message_metadata?: Record; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; } export interface SendReactionRequest { @@ -19273,6 +20055,17 @@ export interface SendReactionResponse { reaction: ReactionResponse; } +export interface SendScheduledMessageNowResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * A message scheduled to be sent later + */ + scheduled_message: ScheduledMessageResponse; +} + export interface SendUserCustomEventRequest { event: UserCustomEventRequest; } @@ -19648,8 +20441,6 @@ export interface StartFrameRecordingResponse { duration: string; } -export interface StartHLSBroadcastingRequest {} - export interface StartHLSBroadcastingResponse { duration: string; /** @@ -19658,8 +20449,6 @@ export interface StartHLSBroadcastingResponse { playlist_url: string; } -export interface StartPolicyTestRunRequest {} - export interface StartRTMPBroadcastsRequest { /** * List of broadcasts to start @@ -19744,8 +20533,6 @@ export interface StartTranscriptionResponse { duration: string; } -export interface StopAllRTMPBroadcastsRequest {} - export interface StopAllRTMPBroadcastsResponse { /** * Duration of the request in milliseconds @@ -19753,7 +20540,13 @@ export interface StopAllRTMPBroadcastsResponse { duration: string; } -export interface StopCampaignRequest {} +export interface StopCampaignResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + campaign?: CampaignResponse; +} export interface StopClosedCaptionsRequest { stop_transcription?: boolean; @@ -19766,8 +20559,6 @@ export interface StopClosedCaptionsResponse { duration: string; } -export interface StopFrameRecordingRequest {} - export interface StopFrameRecordingResponse { /** * Duration of the request in milliseconds @@ -19775,8 +20566,6 @@ export interface StopFrameRecordingResponse { duration: string; } -export interface StopHLSBroadcastingRequest {} - export interface StopHLSBroadcastingResponse { /** * Duration of the request in milliseconds @@ -19872,7 +20661,7 @@ export interface StoriesFeedUpdatedEvent { export interface SubmitActionRequest { /** - * Type of moderation action to perform. One of: mark_reviewed, delete_message, delete_activity, delete_comment, delete_reaction, ban, custom, unban, restore, delete_user, delete_user_messages, unblock, block, shadow_block, unmask, kick_user, end_call, escalate, de_escalate + * Type of moderation action to perform. One of: mark_reviewed, delete_message, delete_activity, delete_comment, delete_reaction, delete_poll, delete_poll_option, delete_poll_answer, ban, custom, unban, restore, delete_user, delete_user_messages, unblock, block, shadow_block, unmask, kick_user, end_call, escalate, de_escalate */ action_type: | 'flag' @@ -19881,6 +20670,9 @@ export interface SubmitActionRequest { | 'delete_activity' | 'delete_comment' | 'delete_reaction' + | 'delete_poll' + | 'delete_poll_option' + | 'delete_poll_answer' | 'ban' | 'custom' | 'unban' @@ -19931,6 +20723,18 @@ export interface SubmitActionRequest { * Configuration for message deletion action */ delete_message?: DeleteMessageRequestPayload; + /** + * Configuration for poll deletion action. Poll deletion is always permanent. + */ + delete_poll?: DeletePollRequestPayload; + /** + * Configuration for deleting the free-text poll answer a review item was raised for. Chat polls only. Deletion is permanent; the poll and other votes are kept. + */ + delete_poll_answer?: DeletePollAnswerRequestPayload; + /** + * Configuration for deleting the single poll option a review item was raised for. Deletion is permanent; the rest of the poll is kept. + */ + delete_poll_option?: DeletePollOptionRequestPayload; /** * Configuration for reaction deletion action */ @@ -20408,8 +21212,6 @@ export interface ThumbnailsSettingsResponse { enabled: boolean; } -export interface Time {} - export interface TimeWindow { from: string; to: string; @@ -20912,6 +21714,10 @@ export interface UnfollowBatchRequest { * List of follow relationships to remove, each with optional keep_history */ follows: Array; + /** + * If true, the batch is queued as a background task, paced per app, and the response has empty follows and a task_id. Server-side only. Supported by the get-or-create endpoint (GetOrCreateUnfollows); UnfollowBatch rejects it. Async batches can apply out of order with other follow writes. Defaults to false. + */ + async?: boolean; /** * Whether to delete the corresponding notification activity (default: false) */ @@ -20928,6 +21734,10 @@ export interface UnfollowBatchResponse { * List of follow relationships that were removed */ follows: Array; + /** + * ID of the background task when the request was sent with async: true + */ + task_id?: string; } export interface UnfollowPair { @@ -21313,7 +22123,10 @@ export interface UpdateAppRequest { reminders_interval?: number; reminders_max_members?: number; reminders_max_per_user?: number; - revoke_tokens_issued_before?: Date; + /** + * Revokes tokens issued before this date + */ + revoke_tokens_issued_before?: Date | null; sns_key?: string; sns_secret?: string; sns_topic_arn?: string; @@ -21342,7 +22155,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; @@ -21516,6 +22329,15 @@ export interface UpdateCampaignRequest { channel_template?: CampaignChannelTemplate; } +export interface UpdateCampaignResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + campaign?: CampaignResponse; + users?: PagerResponse; +} + export interface UpdateChannelPartialRequest { user_id?: string; unset?: Array; @@ -21630,9 +22452,8 @@ export interface UpdateChannelResponse { } export interface UpdateChannelTypeRequest { - automod: 'disabled' | 'simple' | 'AI'; - automod_behavior: 'flag' | 'block' | 'shadow_block'; - max_message_length: number; + automod?: 'disabled' | 'simple' | 'AI'; + automod_behavior?: 'flag' | 'block' | 'shadow_block'; blocklist?: string; blocklist_behavior?: 'flag' | 'block' | 'shadow_block'; connect_events?: boolean; @@ -21640,10 +22461,11 @@ export interface UpdateChannelTypeRequest { custom_events?: boolean; delivery_events?: boolean; mark_messages_pending?: boolean; + max_message_length?: number; message_retention?: string; mutes?: boolean; partition_size?: number; - partition_ttl?: string; + partition_ttl?: string | null; polls?: boolean; /** * Sets the push notification level for a channel type @@ -21674,7 +22496,7 @@ export interface UpdateChannelTypeRequest { */ automod_thresholds?: Thresholds; chat_preferences?: ChatPreferences; - grants?: Record>; + grants?: Record> | null; } export interface UpdateChannelTypeResponse { @@ -22247,6 +23069,14 @@ export interface UpdatePollOptionRequest { * Option text */ text: string; + /** + * Key of the moderation configuration to use. Server-side only. + */ + config_key?: string; + /** + * Team associated with the configuration. Server-side only. + */ + config_team?: string; user_id?: string; /** * Custom data for this object @@ -22259,6 +23089,14 @@ export interface UpdatePollOptionRequest { } export interface UpdatePollPartialRequest { + /** + * Key of the moderation configuration to use. Server-side only. + */ + config_key?: string; + /** + * Team associated with the configuration. Server-side only. + */ + config_team?: string; user_id?: string; /** * Array of field names to unset @@ -22291,6 +23129,14 @@ export interface UpdatePollRequest { * Allow user suggested options */ allow_user_suggested_options?: boolean; + /** + * Key of the moderation configuration to use. Server-side only. + */ + config_key?: string; + /** + * Team associated with the configuration. Server-side only. + */ + config_team?: string; /** * Poll description */ @@ -22454,6 +23300,28 @@ export interface UpdateSIPTrunkResponse { sip_trunk?: SIPTrunkResponse; } +export interface UpdateScheduledMessageRequest { + /** + * New send time (UTC). Must be at least 1 minute and at most the app's maximum lead time from now + */ + scheduled_at?: Date; + /** + * Message data for creating or updating a message + */ + message?: MessageRequest; +} + +export interface UpdateScheduledMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * A message scheduled to be sent later + */ + scheduled_message: ScheduledMessageResponse; +} + export interface UpdateSegmentRequest { /** * The description of the segment (max 256 characters) @@ -22710,6 +23578,10 @@ export interface UpsertActivitiesRequest { * If true, forces moderation to run for server-side requests. By default, server-side requests skip moderation. Client-side requests always run moderation regardless of this field. */ force_moderation?: boolean; + /** + * Server-side only. If true, activities whose id already exists are left unchanged instead of updated: no write, events or notifications. They are listed in skipped_existing and returned as stored. Soft-deleted activities count as existing and are returned with deleted_at set. Default: false. + */ + skip_existing?: boolean; } export interface UpsertActivitiesResponse { @@ -22722,6 +23594,10 @@ export interface UpsertActivitiesResponse { * Total number of mention notification activities created for mentioned users across all activities */ mention_notifications_created?: number; + /** + * IDs of activities left unchanged because they already existed. Only set when skip_existing is true. + */ + skipped_existing?: Array; } export interface UpsertCollectionsRequest { @@ -23074,6 +23950,12 @@ export interface UserBannedEvent { created_by?: UserResponseCommonFields; } +export interface UserChannelBanCountRuleParameters { + banned_by?: string; + threshold?: number; + time_window?: string; +} + export interface UserChannelCountRuleParameters { threshold?: number; time_window?: string; @@ -23735,10 +24617,6 @@ export interface VideoContentParameters { harm_labels?: Array; } -export interface VideoEndCallRequestPayload {} - -export interface VideoKickUserRequestPayload {} - export interface VideoReactionOverTimeResponse { by_minute?: Array; } @@ -23971,7 +24849,6 @@ export type WHEvent = | ({ type: 'moderation.image_analysis.complete'; } & ModerationImageAnalysisCompleteEvent) - | ({ type: 'moderation.intent_detected' } & IntentDetectedEvent) | ({ type: 'moderation.mark_reviewed' } & ModerationMarkReviewedEvent) | ({ type: 'moderation.text_analysis.complete'; @@ -23983,6 +24860,12 @@ export type WHEvent = | ({ type: 'notification.thread_message_new'; } & NotificationThreadMessageNewEvent) + | ({ type: 'poll.closed' } & PollClosedEvent) + | ({ type: 'poll.deleted' } & PollDeletedEvent) + | ({ type: 'poll.updated' } & PollUpdatedEvent) + | ({ type: 'poll.vote_casted' } & PollVoteCastedEvent) + | ({ type: 'poll.vote_changed' } & PollVoteChangedEvent) + | ({ type: 'poll.vote_removed' } & PollVoteRemovedEvent) | ({ type: 'reaction.deleted' } & ReactionDeletedEvent) | ({ type: 'reaction.new' } & ReactionNewEvent) | ({ type: 'reaction.updated' } & ReactionUpdatedEvent) @@ -23991,6 +24874,8 @@ export type WHEvent = | ({ type: 'reminder.updated' } & ReminderUpdatedEvent) | ({ type: 'review_queue_item.new' } & ReviewQueueItemNewEvent) | ({ type: 'review_queue_item.updated' } & ReviewQueueItemUpdatedEvent) + | ({ type: 'scheduled_message.failed' } & ScheduledMessageFailedEvent) + | ({ type: 'scheduled_message.sent' } & ScheduledMessageSentEvent) | ({ type: 'thread.updated' } & ThreadUpdatedEvent) | ({ type: 'user.banned' } & UserBannedEvent) | ({ type: 'user.deactivated' } & UserDeactivatedEvent) diff --git a/src/gen/moderation/ModerationApi.ts b/src/gen/moderation/ModerationApi.ts index bfb8901..4667a9f 100644 --- a/src/gen/moderation/ModerationApi.ts +++ b/src/gen/moderation/ModerationApi.ts @@ -1313,6 +1313,9 @@ export class ModerationApi { delete_activity: request?.delete_activity, delete_comment: request?.delete_comment, delete_message: request?.delete_message, + delete_poll: request?.delete_poll, + delete_poll_answer: request?.delete_poll_answer, + delete_poll_option: request?.delete_poll_option, delete_reaction: request?.delete_reaction, delete_user: request?.delete_user, delete_user_messages: request?.delete_user_messages,