From 85c712eb12903f06faf22c8504e37164f3fb9615 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 14 Oct 2026 07:40:13 -0500 Subject: [PATCH 01/10] fix: add docs snippets; fix open api issues --- CLAUDE.md | 2 +- src/channel.ts | 56 ++---- src/client.ts | 27 ++- src/gen/models/index.ts | 30 +-- src/signing.ts | 19 ++ src/thread_manager.ts | 17 ++ src/types.ts | 10 +- src/utils.ts | 9 +- test/docs-snippets/custom-types.d.ts | 2 + test/docs-snippets/docs-snippets-todo.md | 9 + test/docs-snippets/helpers/clients.ts | 75 ++++---- ...1-client_tokens_and_authentication.test.ts | 6 +- .../02-init_and_users.test.ts | 13 +- .../02-init_and_users/04-user_groups.test.ts | 124 ++++++++----- .../03-channels/01-creating_channels.test.ts | 12 +- .../03-channels/02-query_channels.test.ts | 121 ++++++------ .../03-channels/03-channel_update.test.ts | 20 +- .../03-channels/04-query_members.test.ts | 58 +++--- .../03-channels/05-channel_pagination.test.ts | 6 +- .../03-channels/06-channel_members.test.ts | 22 +-- .../08-archiving.test.ts | 10 +- .../07-channel_management/09-pinning.test.ts | 20 +- .../07-channel_management/10-muting.test.ts | 31 ++-- .../07-channel_management/11-hiding.test.ts | 31 +++- .../12-disabling.test.ts | 25 +-- .../07-channel_management/13-deleting.test.ts | 29 ++- .../07-channel_management/14-freezing.test.ts | 36 ++-- .../15-truncating.test.ts | 21 ++- .../16-channel_invites.test.ts | 16 +- .../pages/04-messages/01-send_message.test.ts | 90 +++++---- .../pages/04-messages/02-file_uploads.test.ts | 171 ++++++++--------- .../pages/04-messages/03-threads.test.ts | 124 ++++++++----- .../04-messages/04-send_reaction.test.ts | 76 ++++---- .../04-messages/05-pinned_messages.test.ts | 69 ++++--- .../pages/04-messages/06-search.test.ts | 101 ++++++---- .../04-messages/07-silent_messages.test.ts | 33 ++-- .../04-messages/09-message_reminders.test.ts | 164 ++++++++-------- .../04-messages/10-message_receipts.test.ts | 14 +- .../pages/05-features/02-events.test.ts | 81 ++++---- .../pages/05-features/03-unread.test.ts | 21 ++- .../05-features/04-typing_indicators.test.ts | 14 +- .../05-features/05-presence_format.test.ts | 43 +++-- .../pages/05-features/07-polls_api.test.ts | 175 +++++++++++------- .../05-features/08-location_sharing.test.ts | 39 ++-- .../pages/05-features/09-translation.test.ts | 24 ++- .../11-slow_mode_and_throttling.test.ts | 16 +- .../05-features/10-advanced/12-drafts.test.ts | 47 +++-- .../10-advanced/13-private_messaging.test.ts | 12 +- .../10-advanced/15-pending_messages.test.ts | 31 ++-- .../12-best_practices/02-moderation.test.ts | 84 +++++---- .../01-plain_js_introduction.test.ts | 22 ++- test/docs-snippets/setup.ts | 8 +- test/types/custom-events.ts | 33 ++++ test/types/custom-filters.ts | 21 +++ test/unit/channel.test.js | 31 +++- test/unit/client.test.js | 27 +++ test/unit/signing.test.js | 20 +- test/unit/utils.test.js | 9 +- v9-to-v10-migration-guide-methods.md | 24 ++- v9-to-v10-migration-guide-other.md | 4 +- v9-to-v10-migration-guide-server-side.md | 23 ++- 61 files changed, 1488 insertions(+), 1020 deletions(-) create mode 100644 test/types/custom-events.ts create mode 100644 test/types/custom-filters.ts diff --git a/CLAUDE.md b/CLAUDE.md index bb712db0c9..0ef367d5e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth - **`connection/ConnectionIdManager.ts` (`client.connectionIdManager`).** The connection id — the WebSocket's, or the long-poll's after an `enableWSFallback` switch — and the only place the client reads it from: `client._getConnectionID()` / `_hasConnectionID()` read through it and keep no copy. The long-poll keeps no copy either: `WSConnection.disconnect()` reads the id before the socket's `disconnect()` invalidates it, and passes it to `WSConnectionFallback.disconnect()` for the close. The server keys watches and presence by it and answers `200` while registering nothing when it is missing, so `ApiClient._doRequest` holds any request `requiresConnectionId` recognises until one exists — the `watch` / `presence` flags, and nothing else. The flagless operations that declare `connection_id` set it themselves: `StreamChat.stopWatchingChannel` (overridden to always wait for the connection id as the gate would — through a handshake or reconnect, honouring the abort signal, and rejecting when there is no connection and none is being established — then send it) and the long-poll fallback; `test/unit/codegen/connectionIdEndpoints.test.ts` pins that generated endpoint set. `StableWSConnection` drives the lifecycle: `arm()` before a socket opens, `resolveConnectionId()` on the hello frame, `invalidate()` from `_applyHealth(false)` (every status transition, so the error paths are covered) and again from `disconnect()`, and a reject when a reconnect gives up. After an `enableWSFallback` switch `WSConnectionFallback` drives it instead, in the same shape: `arm()` in `connect()`, `resolveConnectionId()` before going healthy, `invalidate()` on going closed or disconnected, and a reject when a connect fails (unless cancelled) or a poll hits a non-retryable error. A deliberate close invalidates rather than rejects: `closeConnection()` exists for mobile backgrounding, so a request already waiting keeps waiting for the socket `openConnection()` will build. `reset()` is the harder variant that fails those waiters instead; nothing in the SDK calls it. A caller's abort signal reaches the wait, so an abandoned request does not hold it. - **`wsConnection/` — `WSConnection` (`client.wsConnection`).** A stable wrapper created with the client and never replaced, owning the socket's reactive status (`state`: `isHealthy`, `lastHealthyAt`, `lastUnhealthyAt`; the connection id lives on `client.connectionIdManager`), its configuration, and the one network-status subscription. The live `StableWSConnection` hangs off `.connection` and _is_ replaced per connect, which is why the store cannot live there — it would be unreadable before `connectUser` and would strand subscribers on every `closeConnection()` → `openConnection()` cycle. The store is written on **every** transition, including `disconnect()` and the error paths. It is the only description of the socket's status: there is no companion event. It also performs the `enableWSFallback` switch and holds the long-poll on `.fallback`, which every later `connect()` reuses and `disconnect()` closes alongside the socket. After the switch, `WSConnectionFallback` writes the store through `_setStatus()` instead — so `isHealthy`, `ConnectionRecoveryManager` and the socket-mirroring network reporter all follow the long-poll — `isConnecting` and the network-status routing follow it too, and `.connection` keeps the disconnected socket. - **No `store.ts`** — `StateStore` comes from `@stream-io/state-store`, imported directly wherever it is used and **not** re-exported from the root barrel (see "State and subscription patterns" below). -- **`signing.ts` — one function, `UserFromToken`.** Decodes a JWT payload with the global `atob` and returns `user_id`. Everything else this module used to hold was server-side (JWT minting via `jsonwebtoken`, webhook/SQS/SNS verification via `crypto` + `zlib`) and was removed along with those deps — see `v9-to-v10-migration-guide-server-side.md`. Do not reintroduce secret-holding or HMAC code here; that surface lives in `@stream-io/node-sdk`. +- **`signing.ts` — `UserFromToken` and `DevToken`.** `UserFromToken` decodes a JWT payload with the global `atob` and returns `user_id`; `DevToken` (also `client.devToken`) builds the unsigned development token (fixed `devtoken` signature, accepted only while the app disables auth checks), so it holds no secret. Everything else this module used to hold was server-side (JWT minting via `jsonwebtoken`, webhook/SQS/SNS verification via `crypto` + `zlib`) and was removed along with those deps — see `v9-to-v10-migration-guide-server-side.md`. Do not reintroduce secret-holding or HMAC code here; that surface lives in `@stream-io/node-sdk`. - **`middleware.ts`** — `MiddlewareExecutor` (see "Middleware pipelines" below). Used by composer pipelines, not by client request lifecycle. - **`token_manager.ts`** — handles static tokens and async token providers. Tracks a `loadTokenPromise` so concurrent calls await the same fetch. The constructor takes no arguments: there is no `secret` and no local JWT signing — every token comes from the caller (a string or a `TokenProvider`). Anonymous users may have no token at all; anyone else without one now fails at `getToken()` rather than at `setTokenOrProvider()`. - **Event types.** There is no `events.ts` / `EVENT_MAP` any more (removed in v10). Wire events come from the generated `WSEvent` union (`src/gen/models`) so adding one means regenerating rather than hand-editing. There is no runtime decoder layer any more: `--opt response_dates_as_number` types every server-sent date as the unix-nanosecond number the wire already carries, so `src/gen/model-decoders/` (including `event-decoder-mapping.ts`) is no longer emitted at all, and `src/connection/wsConnection/StableWSConnection.ts` decodes frames with a plain cast. The unit invariant and its traps live in `src/utils/time.ts`; the consumer-facing delta is `v9-to-v10-migration-guide-dates.md`. `src/types.ts` overlays one non-generated member onto the public `Event` union, `LocalEvent` — `channels.queried`, `connection.recovered`, `capabilities.changed`, `message.read_locally`, `offline_reactions.queried`, `live_location_sharing.*`, `connection.fallback_activated` (the `enableWSFallback` switch to long-poll), all dispatched client-side only and never received over the wire — and declares `received_at` on every `WSEvent` member, because `dispatchEvent` stamps it on all of them while the spec omits it from some (`connection.ok`, `connection.error`). diff --git a/src/channel.ts b/src/channel.ts index 88681fb632..b524b6613c 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -28,6 +28,7 @@ import { copyConfigPatch } from './configuration/utils/copyConfigPatch'; import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig'; import { mergeServerRestrictions } from './configuration/utils/serverAuthority'; import type { ServerRestrictions } from './configuration/utils/serverAuthority'; +import type { CustomEventData } from './custom_types'; import type { ChannelDeclarativeConfig } from './configuration/types'; import { mergeDeclarativeMessageOperationsConfig, @@ -638,21 +639,6 @@ export class Channel extends ChannelApi { return this.deleteChannelImage({ url }, requestOptions); } - /** - * Sends an event on this channel. - * - * @param request - For example `{ event: { type: 'message.read' } }`. - * @param requestOptions - Per-request options such as an abort `signal`. Never serialized - * into the request (optional). - * @returns The server response. - */ - override async sendEvent( - request: { event: Event }, - requestOptions?: StreamRequestOptions, - ) { - return await super.sendEvent(request, requestOptions); - } - /** * Queries members. * @@ -828,7 +814,7 @@ export class Channel extends ChannelApi { * @returns The server response. */ async acceptInvite( - options: ChannelUpdateOptions = {}, + options: NonNullable[0]> = {}, requestOptions?: StreamRequestOptions, ) { return await this.update({ accept_invite: true, ...options }, requestOptions); @@ -843,7 +829,7 @@ export class Channel extends ChannelApi { * @returns The server response. */ async rejectInvite( - options: ChannelUpdateOptions = {}, + options: NonNullable[0]> = {}, requestOptions?: StreamRequestOptions, ) { return await this.update({ reject_invite: true, ...options }, requestOptions); @@ -1147,15 +1133,10 @@ export class Channel extends ChannelApi { * @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs} * * @param parentId - Set this field to `message.id` to indicate that the typing event is happening in a thread (optional). - * @param options - Optional override carrying a `user_id` (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). */ - async keystroke( - parentId?: string, - options?: { user_id: string }, - requestOptions?: StreamRequestOptions, - ) { + async keystroke(parentId?: string, requestOptions?: StreamRequestOptions) { if (!this._isTypingIndicatorsEnabled()) { return; } @@ -1171,9 +1152,6 @@ export class Channel extends ChannelApi { event: { type: 'typing.start', parent_id: parentId, - ...(options || {}), - created_at: nowNs(), - custom: {}, }, }, requestOptions, @@ -1201,12 +1179,14 @@ export class Channel extends ChannelApi { await this.sendEvent( { event: { - ...options, type: 'ai_indicator.update', - message_id: messageId, - ai_state: state, - created_at: nowNs(), - custom: {}, + // The API reads the AI indicator fields from `custom` and puts them on the event it + // delivers. They aren't app custom data, hence the assertion. + custom: { + ...options, + message_id: messageId, + ai_state: state, + } as CustomEventData, }, }, requestOptions, @@ -1230,8 +1210,6 @@ export class Channel extends ChannelApi { { event: { type: 'ai_indicator.clear', - created_at: nowNs(), - custom: {}, }, }, requestOptions, @@ -1255,8 +1233,6 @@ export class Channel extends ChannelApi { { event: { type: 'ai_indicator.stop', - created_at: nowNs(), - custom: {}, }, }, requestOptions, @@ -1269,15 +1245,10 @@ export class Channel extends ChannelApi { * @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs} * * @param parentId - Set this field to `message.id` to indicate that the typing event is happening in a thread (optional). - * @param options - Optional override carrying a `user_id` (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). */ - async stopTyping( - parentId?: string, - options?: { user_id: string }, - requestOptions?: StreamRequestOptions, - ) { + async stopTyping(parentId?: string, requestOptions?: StreamRequestOptions) { if (!this._isTypingIndicatorsEnabled()) { return; } @@ -1288,9 +1259,6 @@ export class Channel extends ChannelApi { event: { type: 'typing.stop', parent_id: parentId, - ...(options || {}), - created_at: nowNs(), - custom: {}, }, }, requestOptions, diff --git a/src/client.ts b/src/client.ts index f44dd86c33..78f5294bf5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -9,6 +9,7 @@ import type { ChannelMuteStatus } from './channel_state'; import { ChannelWatchStatus } from './channel_state'; import { ClientState } from './client_state'; import { UploadManager } from './uploadManager'; +import { DevToken } from './signing'; import { TokenManager, type TokenManagerMinimalUser } from './token_manager'; import { ApiClient } from './api-client'; import { @@ -55,6 +56,7 @@ import type { UnBanUserOptions, UpdateUserPartialRequest, UserMuteResponse, + UserRequest, UserResponse, } from './types'; import { isWSFailure } from './errors'; @@ -515,6 +517,17 @@ export class StreamChat extends ChatApi { this.offlineDb = offlineDBInstance; } + /** + * Creates a development token for `userId`, for apps with auth checks disabled. Never use one in + * production. See {@link DevToken}. + * + * @param userId - The id of the user the token is for. + * @returns The development token. + */ + devToken(userId: string) { + return DevToken(userId); + } + getAuthType() { return this.anonymous ? 'anonymous' : 'jwt'; } @@ -939,7 +952,7 @@ export class StreamChat extends ChatApi { * into the request (optional). * @returns A promise that resolves when the connection is set up. */ - async setGuestUser(user: UserResponse, requestOptions?: StreamRequestOptions) { + async setGuestUser(user: UserRequest, requestOptions?: StreamRequestOptions) { const response = await this.createGuest({ user }, requestOptions); const { @@ -1559,8 +1572,8 @@ export class StreamChat extends ChatApi { * @returns The hydrated channel list, or the full response when `withResponse` is `true`. */ async queryChannelsAndHydrate( - options?: QueryChannelsRequest, - stateOptions?: ChannelStateOptions & { withResponse: true }, + options: QueryChannelsRequest | undefined, + stateOptions: ChannelStateOptions & { withResponse: true }, requestOptions?: StreamRequestOptions, ): Promise; async queryChannelsAndHydrate( @@ -2076,8 +2089,8 @@ export class StreamChat extends ChatApi { const parentMessages = response.threads.map((thread) => thread.parent_message!); this.polls.hydratePollCache(parentMessages); - const threads = response.threads.map( - (thread) => new Thread({ client: this, threadData: thread }), + const threads = response.threads.map((thread) => + this.threads.resolveQueried(new Thread({ client: this, threadData: thread })), ); if (optionsWithDefaults.watch) threads.forEach(markThreadChannelWatched); @@ -2119,7 +2132,9 @@ export class StreamChat extends ChatApi { requestOptions, ); - const thread = new Thread({ client: this, threadData: response.thread }); + const thread = this.threads.resolveQueried( + new Thread({ client: this, threadData: response.thread }), + ); if (optionsWithDefaults.watch) markThreadChannelWatched(thread); return thread; diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 59d0fcb444..0ba3a24fb8 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -86,10 +86,14 @@ export type CustomHelper = { // string extends string = true // string extends "custom-string" = false // using this "hack" to omit Record types when custom types are not specified + // Object-valued custom fields are left to the `custom.${string}` index signature: a typed key with + // an object value would conflict with it (filters on nested custom data address `custom.a.b`). [P in keyof T['type'] as string extends P ? never : P extends string - ? `custom.${P}` + ? NonNullable extends string | boolean | number | Date + ? `custom.${P}` + : never : never]-?: { type: NonNullable; operators: T['operators']; @@ -6587,10 +6591,10 @@ export interface QueryBannedUsersPayload { operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; }; }>; - created_at_after?: TimestampNS; - created_at_after_or_equal?: TimestampNS; - created_at_before?: TimestampNS; - created_at_before_or_equal?: TimestampNS; + created_at_after?: Date; + created_at_after_or_equal?: Date; + created_at_before?: Date; + created_at_before_or_equal?: Date; /** * Whether to exclude expired bans or not */ @@ -6891,10 +6895,10 @@ export interface QueryDraftsResponse { } export interface QueryFutureChannelBansPayload { - created_at_after?: TimestampNS; - created_at_after_or_equal?: TimestampNS; - created_at_before?: TimestampNS; - created_at_before_or_equal?: TimestampNS; + created_at_after?: Date; + created_at_after_or_equal?: Date; + created_at_before?: Date; + created_at_before_or_equal?: Date; /** * Whether to exclude expired bans or not */ @@ -6934,10 +6938,10 @@ export interface QueryFutureChannelBansResponse { export interface QueryMembersPayload { type: string; - created_at_after?: TimestampNS; - created_at_after_or_equal?: TimestampNS; - created_at_before?: TimestampNS; - created_at_before_or_equal?: TimestampNS; + created_at_after?: Date; + created_at_after_or_equal?: Date; + created_at_before?: Date; + created_at_before_or_equal?: Date; id?: string; limit?: number; offset?: number; diff --git a/src/signing.ts b/src/signing.ts index 71abe411fa..a790ae754e 100644 --- a/src/signing.ts +++ b/src/signing.ts @@ -14,3 +14,22 @@ export function UserFromToken(token: string) { const data = JSON.parse(payload); return data.user_id as string; } + +/** + * Creates a development token for `userId`: an unsigned JWT the API accepts only while the app + * has auth checks disabled (`disable_auth_checks`). Never use one in production. + * + * @param userId - The id of the user the token is for. + * @returns The development token. + */ +export function DevToken(userId: string) { + // Base64 of the UTF-8 bytes, so a non-Latin-1 id can't make `btoa` throw. + const payload = String.fromCharCode( + ...new TextEncoder().encode(JSON.stringify({ user_id: userId })), + ); + return [ + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', // {"alg": "HS256", "typ": "JWT"} + btoa(payload), + 'devtoken', // fixed signature + ].join('.'); +} diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 3df3f7dfb2..c384906a03 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -186,6 +186,23 @@ export class ThreadManager extends WithSubscriptions { return thread; }; + /** + * The registered instance for a thread a query just built, or the query's own instance when none + * is registered, so `queryThreadsAndHydrate` / `getThreadAndHydrate` hand out the one instance + * events reach: a second one would be left unmanaged by `activate()`. A stale registered thread + * is refreshed from the query, unless it's open (active), in which case it reloads itself. + * + * @internal + */ + public resolveQueried = (incoming: Thread): Thread => { + const stored = this.threadStore.get(incoming.id); + if (!stored) return incoming; + if (stored.hasStaleState && !stored.state.getLatestValue().active) { + stored.hydrateState(incoming); + } + return stored; + }; + /** * Every registered thread — the list's plus the opened ones. * diff --git a/src/types.ts b/src/types.ts index dba037f4b1..610f30bef4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ import type { ChannelStateResponseFields, CreateDeviceRequest, CreatePollRequest, + CustomEvent, DraftPayloadResponse, FlagRequest, Images, @@ -234,7 +235,14 @@ type LocalEvent = ( export type Event = | (WSEvent & { received_at?: TimestampNS }) | LocalEvent - | keyof CustomEventTypes; + | CustomEventTypeUnion; + +// One member per key augmented into `CustomEventTypes`; `never` when none are declared. +type CustomEventTypeUnion = { + [K in keyof CustomEventTypes]: { type: K } & Omit & { + received_at?: TimestampNS; + }; +}[keyof CustomEventTypes]; export type EventType = Event['type'] | 'all'; export type EventHandler = (event: Extract) => void; diff --git a/src/utils.ts b/src/utils.ts index d65fe83838..a2f6905fe2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -187,6 +187,9 @@ function getRandomBytes(length: number): Uint8Array { return bytes; } +const isScalarParam = (value: unknown) => + typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; + export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (params) => { const newParams = []; for (const k in params) { @@ -194,7 +197,11 @@ export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (pa // So, we need to skip the undefined values. if (params[k] === undefined) continue; - if (Array.isArray(params[k]) || typeof params[k] === 'object') { + // Arrays of scalars go comma-separated (`ids=a,b`), the form the API parses for list query + // params; a JSON array there is read as one malformed id and silently matches nothing. + if (Array.isArray(params[k]) && params[k].every(isScalarParam)) { + newParams.push(`${k}=${encodeURIComponent(params[k].join(','))}`); + } else if (Array.isArray(params[k]) || typeof params[k] === 'object') { newParams.push(`${k}=${encodeURIComponent(JSON.stringify(params[k]))}`); } else { newParams.push(`${k}=${encodeURIComponent(params[k])}`); diff --git a/test/docs-snippets/custom-types.d.ts b/test/docs-snippets/custom-types.d.ts index f5bc7e93c5..8a4c1b6c3d 100644 --- a/test/docs-snippets/custom-types.d.ts +++ b/test/docs-snippets/custom-types.d.ts @@ -34,6 +34,8 @@ declare module 'stream-chat' { interface CustomAttachmentData { // _default/04-messages/01-send_message.md myCustomField?: number; + // _default/04-messages/07-silent_messages.md + distance_km?: number; } interface CustomEventData { diff --git a/test/docs-snippets/docs-snippets-todo.md b/test/docs-snippets/docs-snippets-todo.md index cb1efd5c50..4c9105c1ee 100644 --- a/test/docs-snippets/docs-snippets-todo.md +++ b/test/docs-snippets/docs-snippets-todo.md @@ -14,6 +14,8 @@ Pages under `getstream.io/content/docs/` that contain JavaScript snippets. Give Status: `[ ]` todo, `[x]` done, `[~]` done with open questions. +**v10 (2026-10-13):** every page below was ported to stream-chat-js v10 (tests + JavaScript fences on the getstream.io branch `stream-chat-js-v10-docs-snippets`); no page is blocked. Behaviour worth knowing: moderation v2 records only a user's first flag on a message (later flags don't update reason/custom). + App facts (2026-10-05): push v3 (no providers), permissions v2, multi-tenancy off (can be toggled), campaigns on, no webhook/SQS/SNS. Enabled since (not probed yet): message history (`queryMessageHistory`). Probed and **working**: location sharing (enabled by support 2026-10-06), private messaging (restricted visibility, 2026-10-06), pending messages, delivery receipts, translation, review queue, guest users, dynamic partitioning config, multi-tenancy toggle. ## chat/\_default/02-init_and_users @@ -142,6 +144,13 @@ Found while testing the v9 snippets. Not fixed: leave v9 as is. - **Reminder pagination** (`04-messages/09-message_reminders`, backend): with the default sort and a page boundary on a `remind_at: null` reminder, the `next` cursor returned an empty page although more reminders existed. - **`PollOptionResponse` custom data** (`05-features/07-polls_api`, `src/types.ts`): extends `CustomPollData` instead of `CustomPollOptionData`, so custom option fields aren't typed on option responses (or in `PartialPollOptionUpdate.set`). Fixing it is a breaking type change for apps that put option fields on `CustomPollData`: do it in v10. +## Generator fixes (`../chat`, `tools/openapi`) + +Found while migrating the snippets to v10 and patched by hand in `src/gen/models/index.ts`. A `yarn generate-client` overwrites the patches, so fix them at the source, regenerate, and check that `yarn types`, `yarn types:tests` and the docs tests still pass. + +- [ ] **Object-valued custom fields in filters** (`generator/templates/templates/typescript/models.tpl`, `CustomHelper`): a typed `custom.` key is built for every custom data field, including object-valued ones (e.g. `CustomChannelData.source_detail: { user_id: number }`). Its value doesn't fit the `custom.${string}` index signature (`string | boolean | number | Date`), so every filter type built from the custom data stops being assignable, including the SDK's own search sources (`ChannelSearchSource`, `MessageSearchSource`). Fix: only emit typed keys for primitive and `Date` fields (`NonNullable extends string | boolean | number | Date`); object fields fall back to the index signature, which already accepts nested paths like `custom.source_detail.user_id`. Covered by `test/types/custom-filters.ts`. +- [ ] **Request dates typed as response dates** (OpenAPI spec): `created_at_after`, `created_at_after_or_equal`, `created_at_before` and `created_at_before_or_equal` on `QueryMembersPayload`, `QueryBannedUsersPayload` and `QueryFutureChannelBansPayload` are typed `TimestampNS` (the spec types them as numbers, the server-sent direction). They are request fields, sent in the JSON `payload` query param, and the server parses them as RFC 3339: a number fails with `Time.UnmarshalJSON: input is not a JSON string` (`03-channels/04-query_members`, "Paginate by created_at"). Fix the spec so they are request (string) dates, which the generator types as `Date`. Also check `ChannelStopWatchingRequest` (`created_at`, `received_at` typed `TimestampNS`), not investigated. + ## Notes - `chat/_default/03-channels/07-channel_management/17-batch-updates.md`: all `JavaScript` fences were server-only (batch updates and `getTask` fail with a user token: "this endpoint can only be called server side") and were removed, so the page has no client test. diff --git a/test/docs-snippets/helpers/clients.ts b/test/docs-snippets/helpers/clients.ts index a284035998..c8d1a4cbef 100644 --- a/test/docs-snippets/helpers/clients.ts +++ b/test/docs-snippets/helpers/clients.ts @@ -1,7 +1,6 @@ import { StreamClient } from '@stream-io/node-sdk'; import type { UserRequest } from '@stream-io/node-sdk'; import { StreamChat } from '../../../src'; -import type { UserResponse } from '../../../src'; const apiKey = () => process.env.STREAM_API_KEY as string; const apiSecret = () => process.env.STREAM_API_SECRET as string; @@ -16,9 +15,12 @@ export const getServerClient = () => new StreamClient(apiKey(), apiSecret()); export const createUserToken = (userId: string) => getServerClient().generateUserToken({ user_id: userId }); +/** The user `connectUser` takes: only `id` is required, custom data under `custom`. */ +export type TestUser = Parameters[0]; + /** node-sdk requires `enabled` on each privacy setting; unset means enabled. */ const toPrivacySettings = ( - settings: UserResponse['privacy_settings'], + settings: TestUser['privacy_settings'], ): UserRequest['privacy_settings'] => settings && Object.fromEntries( @@ -28,54 +30,41 @@ const toPrivacySettings = ( ]), ); -/** - * Converts a stream-chat-js user (custom fields at the top level) to a node-sdk - * `UserRequest` (custom fields under `custom`). - */ -export const toUserRequest = (user: UserResponse & { id: string }): UserRequest => { - const { - id, - image, - invisible, - language, - name, - privacy_settings, - role, - teams, - teams_role, - ...rest - } = user; - const custom = Object.fromEntries( - Object.entries(rest).filter(([, value]) => value !== undefined), - ); - return { - id, - image, - invisible, - language, - name, - privacy_settings: toPrivacySettings(privacy_settings), - role, - teams, - teams_role: teams_role ?? undefined, - ...(Object.keys(custom).length ? { custom } : {}), - }; -}; +/** Converts a stream-chat-js user to a node-sdk `UserRequest`. */ +export const toUserRequest = ({ + custom, + id, + image, + invisible, + language, + name, + privacy_settings, + role, + teams, + teams_role, +}: TestUser): UserRequest => ({ + id, + image, + invisible, + language, + name, + privacy_settings: toPrivacySettings(privacy_settings), + role, + teams, + teams_role, + ...(custom && Object.keys(custom).length ? { custom } : {}), +}); -/** Creates or replaces users (stream-chat-js shape, custom fields at the top level). */ -export const upsertUsers = ( - serverClient: StreamClient, - users: Array, -) => serverClient.upsertUsers(users.map(toUserRequest)); +/** Creates or replaces users (stream-chat-js shape). */ +export const upsertUsers = (serverClient: StreamClient, users: TestUser[]) => + serverClient.upsertUsers(users.map(toUserRequest)); /** * Client-side client connected as `user` with a user token, the way an app would. * The user is upserted first server-side. * Remember to call `disconnectClients` in `afterAll`. */ -export const getClientSideClient = async ( - user: UserResponse & { id: string }, -): Promise => { +export const getClientSideClient = async (user: TestUser): Promise => { await upsertUsers(getServerClient(), [user]); // `new StreamChat` instead of `getInstance` so several users can be connected in one file. const client = new StreamChat(apiKey(), { allowServerSideConnect: true }); diff --git a/test/docs-snippets/pages/02-init_and_users/01-client_tokens_and_authentication.test.ts b/test/docs-snippets/pages/02-init_and_users/01-client_tokens_and_authentication.test.ts index 8e76e774e8..f9b979027b 100644 --- a/test/docs-snippets/pages/02-init_and_users/01-client_tokens_and_authentication.test.ts +++ b/test/docs-snippets/pages/02-init_and_users/01-client_tokens_and_authentication.test.ts @@ -25,7 +25,7 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () allowServerSideConnect: true, }); try { - await probe.connectUser({ id: userId }, probe.devToken(userId)); + await probe.connectUser({ id: userId }, client.devToken(userId)); await probe.disconnectUser(); return; } catch (error) { @@ -55,7 +55,7 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () allowServerSideConnect: true, }); await expect( - devClient.connectUser({ id: userId }, devClient.devToken(userId)), + devClient.connectUser({ id: userId }, client.devToken(userId)), ).rejects.toThrow(/"StatusCode":401/); await devClient.disconnectUser(); }); @@ -89,7 +89,7 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () // #endregion snippet expect(client.userID).toBe(userId); - expect(client.wsConnection?.isHealthy).toBe(true); + expect(client.wsConnection.isHealthy).toBe(true); const user = await getServerUser(serverClient, userId); expect(user.name).toBe('John Doe'); }); diff --git a/test/docs-snippets/pages/02-init_and_users/02-init_and_users.test.ts b/test/docs-snippets/pages/02-init_and_users/02-init_and_users.test.ts index 08485a5a78..78b3deebe5 100644 --- a/test/docs-snippets/pages/02-init_and_users/02-init_and_users.test.ts +++ b/test/docs-snippets/pages/02-init_and_users/02-init_and_users.test.ts @@ -12,7 +12,7 @@ const apiKey = process.env.STREAM_API_KEY as string; import { StreamChat } from 'stream-chat'; const chatClient = StreamChat.getInstance(apiKey, { - timeout: 6000, + axiosRequestConfig: { timeout: 6000 }, }); // #endregion snippet @@ -33,7 +33,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { it('initializes the client as a singleton', () => { expect(chatClient.key).toBe(apiKey); - expect(chatClient.options.timeout).toBe(6000); + expect(chatClient.axiosInstance.defaults.timeout).toBe(6000); expect(StreamChat.getInstance(apiKey)).toBe(chatClient); }); @@ -54,7 +54,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { // #endregion snippet expect(chatClient.userID).toBe(userId); - expect(chatClient.wsConnection?.isHealthy).toBe(true); + expect(chatClient.wsConnection.isHealthy).toBe(true); const user = await getServerUser(serverClient, userId); expect(user.name).toBe('John Doe'); expect(user.image).toBe('https://getstream.io/random_svg/?name=John'); @@ -64,7 +64,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { // #endregion snippet expect(chatClient.userID).toBeUndefined(); - expect(chatClient.wsConnection?.isHealthy).toBe(false); + expect(chatClient.wsConnection.isHealthy).toBe(false); }); it('connects a user with privacy settings', async () => { @@ -111,12 +111,13 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { // #region snippet docs="_default/02-init_and_users/02-init_and_users.md" heading="XHR Fallback" tab="JavaScript" index=1 // COPY: apiKey="apiKey" - const chatClient = StreamChat.getInstance(apiKey, { enableWSFallback: true }); + const chatClient = StreamChat.getInstance(apiKey); + chatClient.config.set({ client: { wsConnection: { enableWSFallback: true } } }); // #endregion snippet clients.push(chatClient); expect(chatClient).not.toBe(previousClient); - expect(chatClient.options.enableWSFallback).toBe(true); + expect(chatClient.wsConnection.config.enableWSFallback).toBe(true); await chatClient.connectUser({ id: fallbackUserId }, createUserToken(fallbackUserId)); expect(chatClient.userID).toBe(fallbackUserId); diff --git a/test/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts b/test/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts index dfcaeb281a..d7647757b1 100644 --- a/test/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts +++ b/test/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts @@ -95,13 +95,13 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { member_ids: [alice, bob, charlie], }); // #endregion snippet - groupIds.push(user_group.id); + if (user_group) groupIds.push(user_group.id); - expect(user_group.id).toBeTruthy(); - expect(user_group.name).toBe(groupName); - expect(user_group.description).toBe('Product design team members'); - expect(user_group.created_by).toBe(userId); - expect(user_group.members?.map((member) => member.user_id).sort()).toEqual( + expect(user_group?.id).toBeTruthy(); + expect(user_group?.name).toBe(groupName); + expect(user_group?.description).toBe('Product design team members'); + expect(user_group?.created_by).toBe(userId); + expect(user_group?.members?.map((member) => member.user_id).sort()).toEqual( [alice, bob, charlie].sort(), ); }); @@ -109,66 +109,84 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { it('updates a user group', async () => { // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Updating a User Group" tab="JavaScript" index=1 // COPY: groupId="design-team", updatedGroupName="Design & Product Team" - const { user_group } = await client.updateUserGroup(groupId, { - name: updatedGroupName, - description: 'Product and design team members', - }); + const { user_group } = await client.updateUserGroup( + { id: groupId }, + { + name: updatedGroupName, + description: 'Product and design team members', + }, + ); // #endregion snippet - expect(user_group.name).toBe(updatedGroupName); - expect(user_group.description).toBe('Product and design team members'); + expect(user_group?.name).toBe(updatedGroupName); + expect(user_group?.description).toBe('Product and design team members'); }); it('adds members and changes their admin status', async () => { // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Adding Members" tab="JavaScript" index=1 // COPY: groupId="design-team", dave="dave", eve="eve", frank="frank", grace="grace" // Add members as regular members - const { user_group } = await client.addUserGroupMembers(groupId, { - member_ids: [dave, eve, frank], - }); + const { user_group } = await client.addUserGroupMembers( + { id: groupId }, + { + member_ids: [dave, eve, frank], + }, + ); // Add members as group admins - const { user_group: admins } = await client.addUserGroupMembers(groupId, { - member_ids: [grace], - as_admin: true, - }); + const { user_group: admins } = await client.addUserGroupMembers( + { id: groupId }, + { + member_ids: [grace], + as_admin: true, + }, + ); // Promote an existing member to admin - await client.addUserGroupMembers(groupId, { - member_ids: [dave], - as_admin: true, - }); + await client.addUserGroupMembers( + { id: groupId }, + { + member_ids: [dave], + as_admin: true, + }, + ); // Demote an admin back to regular member - await client.addUserGroupMembers(groupId, { - member_ids: [dave], - as_admin: false, - }); + await client.addUserGroupMembers( + { id: groupId }, + { + member_ids: [dave], + as_admin: false, + }, + ); // #endregion snippet - const isAdmin = (id: string, members = user_group.members) => + const isAdmin = (id: string, members = user_group?.members) => members?.find((member) => member.user_id === id)?.is_admin; expect(isAdmin(dave)).toBe(false); expect(isAdmin(frank)).toBe(false); - expect(isAdmin(grace, admins.members)).toBe(true); + expect(isAdmin(grace, admins?.members)).toBe(true); - const { user_group: group } = await client.getUserGroup(groupId); - expect(group.members?.map((member) => member.user_id).sort()).toEqual( + const { user_group: group } = await client.getUserGroup({ id: groupId }); + expect(group?.members?.map((member) => member.user_id).sort()).toEqual( [dave, eve, frank, grace].sort(), ); - expect(isAdmin(dave, group.members)).toBe(false); - expect(isAdmin(grace, group.members)).toBe(true); + expect(isAdmin(dave, group?.members)).toBe(false); + expect(isAdmin(grace, group?.members)).toBe(true); }); it('removes members', async () => { // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Removing Members" tab="JavaScript" index=1 // COPY: groupId="design-team", dave="dave", eve="eve" - const { user_group } = await client.removeUserGroupMembers(groupId, { - member_ids: [dave, eve], - }); + const { user_group } = await client.removeUserGroupMembers( + { id: groupId }, + { + member_ids: [dave, eve], + }, + ); // #endregion snippet - expect(user_group.members?.map((member) => member.user_id).sort()).toEqual( + expect(user_group?.members?.map((member) => member.user_id).sort()).toEqual( [frank, grace].sort(), ); }); @@ -188,14 +206,17 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { it('retrieves a user group', async () => { // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Retrieving a User Group" tab="JavaScript" index=1 // COPY: teamGroupId="design-team", teamId="my-team" - const { user_group } = await client.getUserGroup(teamGroupId, { - team_id: teamId, // only with multi-tenancy - }); + const { user_group } = await client.getUserGroup( + { id: teamGroupId }, + { + team_id: teamId, // only with multi-tenancy + }, + ); // #endregion snippet - expect(user_group.id).toBe(teamGroupId); - expect(user_group.name).toBe(teamGroupName); - expect(user_group.team_id).toBe(teamId); + expect(user_group?.id).toBe(teamGroupId); + expect(user_group?.name).toBe(teamGroupName); + expect(user_group?.team_id).toBe(teamId); }); it('lists user groups', async () => { @@ -203,7 +224,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { const lastId = DOCS_TEST_PREFIX; // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Listing User Groups" tab="JavaScript" index=1 // COPY: lastId="last-id", teamId="my-team" - const { user_groups } = await client.queryUserGroups({ + const { user_groups } = await client.listUserGroups({ limit: 20, id_gt: lastId, // optional: cursor for pagination created_at_gt: '2024-01-01T00:00:00Z', // optional: filter by creation date @@ -235,14 +256,17 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { it('deletes a user group', async () => { // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Deleting a User Group" tab="JavaScript" index=1 // COPY: teamGroupId="design-team", teamId="my-team" - await client.deleteUserGroup(teamGroupId, { - team_id: teamId, // only with multi-tenancy - }); + await client.deleteUserGroup( + { id: teamGroupId }, + { + team_id: teamId, // only with multi-tenancy + }, + ); // #endregion snippet - await expect(client.getUserGroup(teamGroupId, { team_id: teamId })).rejects.toThrow( - /not found/, - ); + await expect( + client.getUserGroup({ id: teamGroupId }, { team_id: teamId }), + ).rejects.toThrow(/not found/); }); }); }); diff --git a/test/docs-snippets/pages/03-channels/01-creating_channels.test.ts b/test/docs-snippets/pages/03-channels/01-creating_channels.test.ts index 3815958f3a..11f9fbcde0 100644 --- a/test/docs-snippets/pages/03-channels/01-creating_channels.test.ts +++ b/test/docs-snippets/pages/03-channels/01-creating_channels.test.ts @@ -34,7 +34,7 @@ describe('_default/03-channels/01-creating_channels.md', () => { // #region snippet docs="_default/03-channels/01-creating_channels.md" heading="Creating a Channel Using a Channel ID" tab="JavaScript" index=1 // COPY: travel="travel" const channel = client.channel('messaging', travel, { - name: 'Awesome channel about traveling', + custom: { name: 'Awesome channel about traveling' }, }); // Here, 'travel' will be the channel ID await channel.create(); @@ -52,7 +52,7 @@ describe('_default/03-channels/01-creating_channels.md', () => { // #region snippet docs="_default/03-channels/01-creating_channels.md" heading="Distinct Channels" tab="JavaScript" index=1 // COPY: thierry="thierry", tommaso="tommaso" const channel = client.channel('messaging', { - members: [thierry, tommaso], + members: [{ user_id: thierry }, { user_id: tommaso }], }); await channel.create(); // #endregion snippet @@ -62,7 +62,9 @@ describe('_default/03-channels/01-creating_channels.md', () => { expect(Object.keys(channel.state.members).sort()).toEqual([thierry, tommaso].sort()); // Same members, in any order: the same channel. - const again = client.channel('messaging', { members: [tommaso, thierry] }); + const again = client.channel('messaging', { + members: [{ user_id: tommaso }, { user_id: thierry }], + }); await again.create(); expect(again.cid).toBe(channel.cid); }); @@ -79,8 +81,8 @@ describe('_default/03-channels/01-creating_channels.md', () => { // #endregion snippet // get-or-create: the channel didn't exist before. - expect(state.channel.id).toBe(travelChannel); - expect(state.channel.created_by?.id).toBe(thierry); + expect(state.channel?.id).toBe(travelChannel); + expect(state.channel?.created_by?.id).toBe(thierry); expect(Array.isArray(state.messages)).toBe(true); // Watched: events of the channel now reach this client over the WebSocket. const received = new Promise((resolve) => { diff --git a/test/docs-snippets/pages/03-channels/02-query_channels.test.ts b/test/docs-snippets/pages/03-channels/02-query_channels.test.ts index f711a828fe..a870677d12 100644 --- a/test/docs-snippets/pages/03-channels/02-query_channels.test.ts +++ b/test/docs-snippets/pages/03-channels/02-query_channels.test.ts @@ -54,15 +54,15 @@ describe(DOCS, () => { it('queries channels', async () => { // #region snippet docs="_default/03-channels/02-query_channels.md" heading="" tab="JavaScript" index=1 // COPY: thierry="thierry" - const channels = await chatClient.queryChannels( - { type: 'messaging', members: { $in: [thierry] } }, - [{ last_message_at: -1 }], - { limit: 15 }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: { type: 'messaging', members: { $in: [thierry] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 15, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst); - expect(channels[0].state.messages.length).toBeGreaterThan(0); + expect(channels[0].messagePaginator.items?.length).toBeGreaterThan(0); }); it('filters messaging channels by member', async () => { @@ -71,7 +71,10 @@ describe(DOCS, () => { const filter = { members: { $in: [thierry] }, type: 'messaging' }; // #endregion snippet - const channels = await chatClient.queryChannels(filter, {}, { limit: 30 }); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: filter, + limit: 30, + }); expect(channels.map((c) => c.cid).sort()).toEqual([...newestFirst].sort()); }); @@ -81,11 +84,12 @@ describe(DOCS, () => { // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Pagination" tab="JavaScript" index=1 // COPY: thierry="thierry" // Get channels 11-30 - const channels = await authClient.queryChannels( - { members: { $in: [thierry] } }, - { last_message_at: -1 }, - { limit: 20, offset: 10 }, - ); + const channels = await authClient.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [thierry] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + offset: 10, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst.slice(10)); @@ -97,9 +101,9 @@ describe(DOCS, () => { const channel = chatClient.channel('messaging', channelId); // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Channel Creation and Watching" tab="JavaScript" index=1 - channel.create(); - channel.query(); - channel.watch(); + await channel.create(); + await channel.query(); + await channel.watch(); // #endregion snippet const created = await retry( @@ -123,24 +127,32 @@ describe(DOCS, () => { const typeFilter = { type: 'messaging' }; // #endregion snippet - const byCid = await chatClient.queryChannels(cidFilter); + const byCid = await chatClient.queryChannelsAndHydrate({ + filter_conditions: cidFilter, + }); expect(byCid.map((c) => c.cid)).toEqual([channelCID]); - const byMembers = await chatClient.queryChannels(membersFilter, {}, { limit: 30 }); + const byMembers = await chatClient.queryChannelsAndHydrate({ + filter_conditions: membersFilter, + limit: 30, + }); expect(byMembers).toHaveLength(CHANNEL_COUNT); - const byType = await chatClient.queryChannels(typeFilter, {}, { limit: 1 }); + const byType = await chatClient.queryChannelsAndHydrate({ + filter_conditions: typeFilter, + limit: 1, + }); expect(byType.every((c) => c.type === 'messaging')).toBe(true); }); it('sorts by last_message_at', async () => { // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Sort Best Practices" tab="JavaScript" index=1 - const sort = { last_message_at: -1 }; + const sort = [{ field: 'last_message_at', direction: -1 }]; // #endregion snippet - const channels = await chatClient.queryChannels( - { members: { $in: [thierry] } }, - { last_message_at: sort.last_message_at < 0 ? -1 : 1 }, - { limit: 30 }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [thierry] } }, + sort, + limit: 30, + }); expect(channels.map((c) => c.cid)).toEqual(newestFirst); }); @@ -163,17 +175,15 @@ describe(DOCS, () => { // #endregion snippet // Both are valid filters: they work, they are just slow at scale. - const broad = await chatClient.queryChannels(broadFilter, {}, { limit: 1 }); + const broad = await chatClient.queryChannelsAndHydrate({ + filter_conditions: broadFilter, + limit: 1, + }); expect(broad.every((c) => c.type === 'messaging')).toBe(true); - // `nestedFilter.$and` is inferred as a plain array, not the `ArrayOneOrMore` tuple - // `ChannelFilters` asks for, so send it as the raw request body. Filtering on - // `disabled` needs the ReadDisabledChannel permission, which users don't have - // (the server test runs it). + // Filtering on `disabled` needs the ReadDisabledChannel permission, which users + // don't have, so the nested filter is rejected client-side. await expect( - chatClient.post(`${chatClient.baseURL}/channels`, { - filter_conditions: nestedFilter, - limit: 1, - }), + chatClient.queryChannels({ filter_conditions: nestedFilter, limit: 1 }), ).rejects.toThrow(/ReadDisabledChannel/); }); @@ -195,15 +205,12 @@ describe(DOCS, () => { // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Using Predefined Filters in Production" tab="JavaScript" index=1 // COPY: userMessagingChannels="user_messaging_channels" // Production-ready: Use Predefined Filter - const channels = await chatClient.queryChannels( - {}, // filter_conditions ignored with predefined_filter - { last_message_at: -1 }, - { - predefined_filter: userMessagingChannels, - filter_values: { user_id: userId }, - limit: 20, - }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + predefined_filter: userMessagingChannels, + filter_values: { user_id: userId }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst); @@ -214,15 +221,12 @@ describe(DOCS, () => { // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Using Predefined Filters" tab="JavaScript" index=1 // COPY: userMessagingChannels="user_messaging_channels", user123="user123" - const channels = await chatClient.queryChannels( - {}, // filter_conditions ignored with predefined_filter - { last_message_at: -1 }, - { - predefined_filter: userMessagingChannels, - filter_values: { user_id: user123 }, - limit: 20, - }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + predefined_filter: userMessagingChannels, + filter_values: { user_id: user123 }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst); @@ -239,18 +243,21 @@ describe(DOCS, () => { }; // #endregion snippet - const channels = await chatClient.queryChannels(filter, {}, { limit: 30 }); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: filter, + limit: 30, + }); expect(channels).toHaveLength(CHANNEL_COUNT); }); it('paginates with a members filter', async () => { // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Pagination Best Practices" tab="JavaScript" index=1 // Efficient pagination with members filter - const channels = await chatClient.queryChannels( - { type: 'messaging', members: { $in: [userId] } }, - { last_message_at: -1 }, - { limit: 20 }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: { type: 'messaging', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst); diff --git a/test/docs-snippets/pages/03-channels/03-channel_update.test.ts b/test/docs-snippets/pages/03-channels/03-channel_update.test.ts index 3573cea9be..cafc7c4e86 100644 --- a/test/docs-snippets/pages/03-channels/03-channel_update.test.ts +++ b/test/docs-snippets/pages/03-channels/03-channel_update.test.ts @@ -33,9 +33,11 @@ describe('_default/03-channels/03-channel_update.md', () => { // #region snippet docs="_default/03-channels/03-channel_update.md" heading="Partial Update" tab="JavaScript" index=1 // Here's a channel with some custom field data that might be useful const channel = client.channel(type, id, { - source: 'user', - source_detail: { user_id: 123 }, - channel_detail: { topic: 'Plants and Animals', rating: 'pg' }, + custom: { + source: 'user', + source_detail: { user_id: 123 }, + channel_detail: { topic: 'Plants and Animals', rating: 'pg' }, + }, }); await channel.create(); @@ -60,20 +62,22 @@ describe('_default/03-channels/03-channel_update.md', () => { it('fully updates a channel', async () => { const channel = client.channel('messaging', uniqueId('channel'), { - source: 'user', + custom: { source: 'user' }, }); await channel.create(); if (channel.cid) cleanup.channels.push(channel.cid); // #region snippet docs="_default/03-channels/03-channel_update.md" heading="Full Update" tab="JavaScript" index=1 const update = await channel.update({ - name: 'myspecialchannel', - color: 'green', + data: { custom: { name: 'myspecialchannel', color: 'green' } }, }); // #endregion snippet - expect(update.channel).toMatchObject({ name: 'myspecialchannel', color: 'green' }); + expect(update.channel?.custom).toMatchObject({ + name: 'myspecialchannel', + color: 'green', + }); // Fields not included in a full update are removed. - expect(update.channel.source).toBeUndefined(); + expect(update.channel?.custom.source).toBeUndefined(); }); }); diff --git a/test/docs-snippets/pages/03-channels/04-query_members.test.ts b/test/docs-snippets/pages/03-channels/04-query_members.test.ts index 4e799b3dfe..c5c936d311 100644 --- a/test/docs-snippets/pages/03-channels/04-query_members.test.ts +++ b/test/docs-snippets/pages/03-channels/04-query_members.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, ChannelMemberAPIResponse, StreamChat } from '../../../../src'; +import type { Channel, MembersResponse, StreamChat } from '../../../../src'; +import { nsToDate } from '../../../../src'; import { disconnectClients, getClientSideClient, @@ -12,7 +13,7 @@ const DOCS = '_default/03-channels/04-query_members.md'; /** Awaits every call recorded by a `queryMembers` spy (the docs don't keep the results). */ const spiedResults = async (spy: { mock: { results: Array<{ value: unknown }> } }) => - (await Promise.all(spy.mock.results.map((r) => r.value))) as ChannelMemberAPIResponse[]; + (await Promise.all(spy.mock.results.map((r) => r.value))) as MembersResponse[]; describe(DOCS, () => { const serverClient = getServerClient(); @@ -32,7 +33,9 @@ describe(DOCS, () => { { id: bob, name: 'bob' }, ]); client = await getClientSideClient({ id: owner }); - channel = client.channel('messaging', uniqueId('channel'), { members: [owner] }); + channel = client.channel('messaging', uniqueId('channel'), { + members: [{ user_id: owner }], + }); await channel.create(); if (channel.cid) cleanup.channels.push(channel.cid); // Added one by one so the members have distinct created_at values. @@ -51,13 +54,15 @@ describe(DOCS, () => { // #region snippet docs="_default/03-channels/04-query_members.md" heading="" tab="JavaScript" index=1 // Query members by user name - channel.queryMembers({ name: 'tommaso' }); + await channel.queryMembers({ payload: { filter_conditions: { name: 'tommaso' } } }); // Autocomplete members by user name - channel.queryMembers({ name: { $autocomplete: 'tom' } }); + await channel.queryMembers({ + payload: { filter_conditions: { name: { $autocomplete: 'tom' } } }, + }); // Query all members - channel.queryMembers({}); + await channel.queryMembers(); // #endregion snippet const [byName, autocomplete, all] = await spiedResults(spy); @@ -71,39 +76,50 @@ describe(DOCS, () => { it('paginates channel members', async () => { // The docs' placeholder: the last member of a previous page (here the newest member). - const { members: newestFirst } = await channel.queryMembers( - {}, - { created_at: -1 }, - {}, - ); + const { members: newestFirst } = await channel.queryMembers({ + payload: { sort: [{ field: 'created_at', direction: -1 }] }, + }); const lastMember = newestFirst[0]; const spy = vi.spyOn(channel, 'queryMembers'); // #region snippet docs="_default/03-channels/04-query_members.md" heading="Paginating Channel Members" tab="JavaScript" index=1 // Returns up to 100 members ordered by created_at descending - await channel.queryMembers({}, { created_at: -1 }, {}); + await channel.queryMembers({ + payload: { sort: [{ field: 'created_at', direction: -1 }] }, + }); // Returns up to 100 members ordered by user_id descending - await channel.queryMembers({}, { user_id: -1 }, {}); + await channel.queryMembers({ + payload: { sort: [{ field: 'user_id', direction: -1 }] }, + }); // Paginate by user_id in ascending order - await channel.queryMembers({}, { user_id: 1 }, { user_id_lt: lastMember.user_id }); + await channel.queryMembers({ + payload: { + sort: [{ field: 'user_id', direction: 1 }], + user_id_lt: lastMember.user_id, + }, + }); // Paginate by created_at in descending order - await channel.queryMembers( - {}, - { created_at: -1 }, - { created_at_before: lastMember.created_at }, - ); + // (created_at is a unix-nanosecond number: nsToDate from "stream-chat" converts it back to a Date) + await channel.queryMembers({ + payload: { + sort: [{ field: 'created_at', direction: -1 }], + created_at_before: nsToDate(lastMember.created_at), + }, + }); // Paginate using offset - await channel.queryMembers({}, { created_at: -1 }, { offset: 20 }); + await channel.queryMembers({ + payload: { sort: [{ field: 'created_at', direction: -1 }], offset: 20 }, + }); // #endregion snippet const [byCreatedAt, byUserIdDesc, userIdPage, createdAtPage, offsetPage] = await spiedResults(spy); spy.mockRestore(); - const ids = (r: ChannelMemberAPIResponse) => r.members.map((m) => m.user_id ?? ''); + const ids = (r: MembersResponse) => r.members.map((m) => m.user_id ?? ''); const all = [owner, tommaso, jane, bob]; expect(ids(byCreatedAt)).toEqual([bob, jane, tommaso, owner]); diff --git a/test/docs-snippets/pages/03-channels/05-channel_pagination.test.ts b/test/docs-snippets/pages/03-channels/05-channel_pagination.test.ts index fe89188ff5..2760e9d700 100644 --- a/test/docs-snippets/pages/03-channels/05-channel_pagination.test.ts +++ b/test/docs-snippets/pages/03-channels/05-channel_pagination.test.ts @@ -27,12 +27,14 @@ describe(DOCS, () => { await serverClient.upsertUsers(others.map((id) => ({ id }))); client = await getClientSideClient({ id: owner }); channel = client.channel('messaging', uniqueId('channel'), { - members: [owner, ...others], + members: [owner, ...others].map((user_id) => ({ user_id })), }); await channel.create(); if (channel.cid) cleanup.channels.push(channel.cid); for (let i = 0; i < MESSAGE_COUNT; i++) { - const { message } = await channel.sendMessage({ text: `message ${i}` }); + const { message } = await channel.sendMessage({ + message: { text: `message ${i}` }, + }); messageIds.push(message.id); } }); diff --git a/test/docs-snippets/pages/03-channels/06-channel_members.test.ts b/test/docs-snippets/pages/03-channels/06-channel_members.test.ts index 31f1f02635..2fde9c14b5 100644 --- a/test/docs-snippets/pages/03-channels/06-channel_members.test.ts +++ b/test/docs-snippets/pages/03-channels/06-channel_members.test.ts @@ -26,7 +26,7 @@ describe('_default/03-channels/06-channel_members.md', () => { /** A channel created by the connected user (the owner), with the given extra members. */ const createOwnedChannel = async (members: string[] = []) => { const channel = client.channel('messaging', uniqueId('channel'), { - members: [owner, ...members], + members: [owner, ...members].map((user_id) => ({ user_id })), }); await channel.create(); if (channel.cid) cleanup.channels.push(channel.cid); @@ -82,7 +82,7 @@ describe('_default/03-channels/06-channel_members.md', () => { await channel.addMembers([{ user_id: jamesBond, channel_role: 'channel_moderator' }]); // Add new channel member with custom data - await channel.addMembers([{ user_id: alecTrevelyan, code_name: '006' }]); + await channel.addMembers([{ user_id: alecTrevelyan, custom: { code_name: '006' } }]); // #endregion snippet const members = await membersOf(channel); @@ -101,8 +101,8 @@ describe('_default/03-channels/06-channel_members.md', () => { // COPY: jamesBond="james_bond", alecTrevelyan="alec_trevelyan" const channel = client.channel('messaging', randomID, { members: [ - { user_id: jamesBond, code_name: '007' }, - { user_id: alecTrevelyan, code_name: '006' }, + { user_id: jamesBond, custom: { code_name: '007' } }, + { user_id: alecTrevelyan, custom: { code_name: '006' } }, ], }); await channel.create(); @@ -147,7 +147,7 @@ describe('_default/03-channels/06-channel_members.md', () => { it('hides history', async () => { const channel = await createOwnedChannel(); - await channel.sendMessage({ text: 'Before thierry joined' }); + await channel.sendMessage({ message: { text: 'Before thierry joined' } }); // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Hide History" tab="JavaScript" index=1 // COPY: thierry="thierry" @@ -160,14 +160,14 @@ describe('_default/03-channels/06-channel_members.md', () => { it('hides history before a date', async () => { const channel = await createOwnedChannel(); - await channel.sendMessage({ text: 'Sent today' }); + await channel.sendMessage({ message: { text: 'Sent today' } }); // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Hide History Before a Specific Date" tab="JavaScript" index=1 // COPY: thierry="thierry" const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 7); // Last 7 days await channel.addMembers([thierry], undefined, { - hide_history_before: cutoff.toISOString(), + hide_history_before: cutoff, }); // #endregion snippet @@ -201,15 +201,15 @@ describe('_default/03-channels/06-channel_members.md', () => { // Add custom data while creating the channel const channel = client.channel('messaging', randomID, { members: [ - { user_id: userid1, key1: 'value1' }, - { user_id: userid2, key1: 'value1' }, - { user_id: userid3, key2: 'value2' }, + { user_id: userid1, custom: { key1: 'value1' } }, + { user_id: userid2, custom: { key1: 'value1' } }, + { user_id: userid3, custom: { key2: 'value2' } }, ], }); await channel.create(); // Add custom data with addMembers method - await channel.addMembers([{ user_id: userid1, key1: 'value1' }]); + await channel.addMembers([{ user_id: userid1, custom: { key1: 'value1' } }]); // #endregion snippet const members = await membersOf(channel); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts b/test/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts index 6e77178368..654bc7d423 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts +++ b/test/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts @@ -19,7 +19,9 @@ describe('_default/03-channels/07-channel_management/08-archiving.md', () => { beforeAll(async () => { cleanup.users.push(userId); client = await getClientSideClient({ id: userId }); - const channel = client.channel('messaging', channelId, { members: [userId] }); + const channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.create(); }); @@ -41,7 +43,9 @@ describe('_default/03-channels/07-channel_management/08-archiving.md', () => { await channel.archive(); // Query for channels that are not archived - const resp = await client.queryChannels({ archived: false }); + const resp = await client.queryChannelsAndHydrate({ + filter_conditions: { archived: false }, + }); // Unarchive the channel await channel.unarchive(); @@ -49,7 +53,7 @@ describe('_default/03-channels/07-channel_management/08-archiving.md', () => { const [archived] = await Promise.all(archiveSpy.mock.results.map((r) => r.value)); archiveSpy.mockRestore(); - expect(archived?.archived_at).toBeTruthy(); + expect(archived?.channel_member?.archived_at).toBeTruthy(); expect(resp.map((c) => c.cid)).not.toContain(`messaging:${channelId}`); const member = await getServerMember(serverClient, `messaging:${channelId}`, userId); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts b/test/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts index 3a429b8eb1..297f35d072 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts +++ b/test/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts @@ -26,10 +26,14 @@ describe('_default/03-channels/07-channel_management/09-pinning.md', () => { cleanup.channels.push(`messaging:${channelId}`, `messaging:${otherChannelId}`); // created first, so without the pinned_at sort it would come after the other channel await client - .channel('messaging', channelId, { members: [userId, amyId, benId] }) + .channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: amyId }, { user_id: benId }], + }) .create(); await client - .channel('messaging', otherChannelId, { members: [userId, amyId, benId] }) + .channel('messaging', otherChannelId, { + members: [{ user_id: userId }, { user_id: amyId }, { user_id: benId }], + }) .create(); }); @@ -48,13 +52,15 @@ describe('_default/03-channels/07-channel_management/09-pinning.md', () => { await channel.pin(); // Query for channels that are pinned - const resp = await client.queryChannels({ pinned: true }); + const resp = await client.queryChannelsAndHydrate({ + filter_conditions: { pinned: true }, + }); // Query for channels for specific members and show pinned first - const pinnedFirst = await client.queryChannels( - { members: { $in: [amyId, benId] } }, - { pinned_at: -1 }, - ); + const pinnedFirst = await client.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [amyId, benId] } }, + sort: [{ field: 'pinned_at', direction: -1 }], + }); // Unpin the channel await channel.unpin(); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts b/test/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts index c04adc029f..6527442678 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts +++ b/test/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { StreamChat } from '../../../../../src'; +import type { Channel } from '../../../../../src'; import { createUserToken, disconnectClients, getServerClient, } from '../../../helpers/clients'; +import type { TestUser } from '../../../helpers/clients'; import { Cleanup } from '../../../helpers/cleanup'; import { uniqueId } from '../../../helpers/ids'; import { retry } from '../../../helpers/wait'; @@ -16,7 +18,7 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { const channelId = uniqueId('channel'); const otherChannelId = uniqueId('other'); const premutedChannelId = uniqueId('premuted'); - const user = { id: userId }; + const user: TestUser = { id: userId }; const token = createUserToken(userId); const client = new StreamChat(process.env.STREAM_API_KEY as string, { allowServerSideConnect: true, @@ -68,7 +70,8 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { await channel.mute({ expiration: 10000 }); // check if channel is muted - // { muted: true | false, createdAt: Date | null, expiresAt: Date | null} + // { muted: true | false, createdAt: number | null, expiresAt: number | null } + // (timestamps in unix nanoseconds) channel.muteStatus(); // #endregion snippet @@ -82,28 +85,30 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { return Promise.resolve(s); }); expect(status.muted).toBe(true); - expect(status.expiresAt).toBeInstanceOf(Date); + expect(typeof status.expiresAt).toBe('number'); }); it('queries muted and not muted channels', async () => { // make sure the mute doesn't expire during the query await client.channel('messaging', channelId).mute(); - const spy = vi.spyOn(client, 'queryChannels'); + const spy = vi.spyOn(client, 'queryChannelsAndHydrate'); // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Query Muted Channels" tab="JavaScript" index=1 // retrieve all channels excluding muted ones - await client.queryChannels({ members: { $in: [userId] }, muted: false }); + await client.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [userId] }, muted: false }, + }); // retrieve all muted channels - await client.queryChannels({ muted: true }); + await client.queryChannelsAndHydrate({ filter_conditions: { muted: true } }); // #endregion snippet - const [notMuted, muted] = await Promise.all(spy.mock.results.map((r) => r.value)); + const [notMuted, muted]: Channel[][] = await Promise.all( + spy.mock.results.map((r) => r.value), + ); spy.mockRestore(); - expect(notMuted.map((c: { cid: string }) => c.cid)).toEqual([ - `messaging:${otherChannelId}`, - ]); - expect(muted.map((c: { cid: string }) => c.cid).sort()).toEqual( + expect(notMuted.map((c) => c.cid)).toEqual([`messaging:${otherChannelId}`]); + expect(muted.map((c) => c.cid).sort()).toEqual( [`messaging:${channelId}`, `messaging:${premutedChannelId}`].sort(), ); }); @@ -116,7 +121,9 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { await channel.unmute(); // #endregion snippet - const muted = await client.queryChannels({ muted: true }); + const muted = await client.queryChannelsAndHydrate({ + filter_conditions: { muted: true }, + }); expect(muted.map((c) => c.cid)).toEqual([`messaging:${premutedChannelId}`]); }); }); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts b/test/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts index bf6736d2e0..1a4254187e 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts +++ b/test/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts @@ -23,7 +23,11 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); cleanup.channels.push(`messaging:${channelId}`); - await client.channel('messaging', channelId, { members: [userId, otherId] }).create(); + await client + .channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }) + .create(); await sendServerMessage(serverClient, `messaging:${channelId}`, { text: 'before hiding', user_id: otherId, @@ -50,7 +54,7 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { await channel.hide(); // hides the channel until a new message is added there. This also clears the history for the user - await channel.hide(null, true); + await channel.hide({ clear_history: true }); // shows a previously hidden channel await channel.show(); @@ -58,7 +62,12 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { // hiding an already hidden channel sends no second `channel.hidden` event await retry(() => { - expect(events.map((e) => [e.type, e.clear_history])).toEqual([ + expect( + events.map((e) => [ + e.type, + e.type === 'channel.hidden' ? e.clear_history : undefined, + ]), + ).toEqual([ ['channel.hidden', false], ['channel.visible', undefined], ]); @@ -67,7 +76,9 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { unsubscribe(); // shown again - const [shown] = await client.queryChannels({ cid: `messaging:${channelId}` }); + const [shown] = await client.queryChannelsAndHydrate({ + filter_conditions: { cid: `messaging:${channelId}` }, + }); expect(shown?.cid).toBe(`messaging:${channelId}`); // and the history was cleared for this user (read server-side: the client's local // state keeps the messages it already had, since no second hidden event arrived) @@ -82,8 +93,12 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { await channel.hide(); const filter = { cid: `messaging:${channelId}` }; - expect(await client.queryChannels(filter)).toEqual([]); - const hidden = await client.queryChannels({ ...filter, hidden: true }); + expect(await client.queryChannelsAndHydrate({ filter_conditions: filter })).toEqual( + [], + ); + const hidden = await client.queryChannelsAndHydrate({ + filter_conditions: { ...filter, hidden: true }, + }); expect(hidden.map((c) => c.cid)).toEqual([`messaging:${channelId}`]); await sendServerMessage(serverClient, `messaging:${channelId}`, { @@ -92,7 +107,9 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { }); // unhiding on a new message is asynchronous await retry(async () => { - const [found] = await client.queryChannels(filter); + const [found] = await client.queryChannelsAndHydrate({ + filter_conditions: filter, + }); expect(found?.cid).toBe(`messaging:${channelId}`); }); // after the cleared history, only the new message is visible to the user diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts b/test/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts index f26a7aec83..a4a7d2103f 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts +++ b/test/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { StreamChat } from '../../../../../src'; +import type { Channel, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -21,7 +21,7 @@ describe('_default/03-channels/07-channel_management/12-disabling.md', () => { client = await getClientSideClient({ id: userId }); for (const id of [enabledId, disabledId]) { cleanup.channels.push(`messaging:${id}`); - await client.channel('messaging', id, { members: [userId] }).create(); + await client.channel('messaging', id, { members: [{ user_id: userId }] }).create(); } await serverClient.chat .channel('messaging', disabledId) @@ -34,13 +34,13 @@ describe('_default/03-channels/07-channel_management/12-disabling.md', () => { }); it('filters out disabled channels', async () => { - const spy = vi.spyOn(client, 'queryChannels'); + const spy = vi.spyOn(client, 'queryChannelsAndHydrate'); // #region snippet docs="_default/03-channels/07-channel_management/12-disabling.md" heading="" tab="JavaScript" index=1 - await client.queryChannels({ disabled: false }); + await client.queryChannelsAndHydrate({ filter_conditions: { disabled: false } }); // #endregion snippet - const results = await Promise.all(spy.mock.results.map((r) => r.value)); + const results: Channel[][] = await Promise.all(spy.mock.results.map((r) => r.value)); spy.mockRestore(); expect(results).toHaveLength(1); const cids = results.flat().map((c) => c.cid); @@ -50,8 +50,10 @@ describe('_default/03-channels/07-channel_management/12-disabling.md', () => { it('disabled channels are excluded client-side even without the filter', async () => { const cids = ( - await client.queryChannels({ - cid: { $in: [`messaging:${enabledId}`, `messaging:${disabledId}`] }, + await client.queryChannelsAndHydrate({ + filter_conditions: { + cid: { $in: [`messaging:${enabledId}`, `messaging:${disabledId}`] }, + }, }) ).map((c) => c.cid); expect(cids).toEqual([`messaging:${enabledId}`]); @@ -59,13 +61,14 @@ describe('_default/03-channels/07-channel_management/12-disabling.md', () => { it('client-side reads and writes of a disabled channel fail, and disabling is server-only', async () => { const disabled = client.channel('messaging', disabledId); - await expect(disabled.watch()).rejects.toThrow(/code 17.*is disabled/); - await expect(disabled.sendMessage({ text: 'hi' })).rejects.toThrow( - /code 17.*is disabled/, + const disabledError = { code: 17, message: expect.stringMatching(/is disabled/) }; + await expect(disabled.watch()).rejects.toMatchObject(disabledError); + await expect(disabled.sendMessage({ message: { text: 'hi' } })).rejects.toMatchObject( + disabledError, ); const enabled = client.channel('messaging', enabledId); - await expect(enabled.update({ disabled: true })).rejects.toThrow( + await expect(enabled.update({ data: { disabled: true } })).rejects.toThrow( 'changing channel disabled is not allowed client-side', ); await expect(enabled.updatePartial({ set: { disabled: true } })).rejects.toThrow( diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts b/test/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts index ddc23095c9..b73a11fbb4 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts +++ b/test/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts @@ -18,9 +18,11 @@ describe('_default/03-channels/07-channel_management/13-deleting.md', () => { const createChannel = async (name: string) => { const id = uniqueId(name); cleanup.channels.push(`messaging:${id}`); - const channel = client.channel('messaging', id, { members: [userId, otherId] }); + const channel = client.channel('messaging', id, { + members: [{ user_id: userId }, { user_id: otherId }], + }); await channel.create(); - await channel.sendMessage({ text: 'before deleting' }); + await channel.sendMessage({ message: { text: 'before deleting' } }); return channel; }; @@ -42,9 +44,11 @@ describe('_default/03-channels/07-channel_management/13-deleting.md', () => { const destroy = await channel.delete(); // #endregion snippet - expect(destroy.channel.cid).toBe(channel.cid); - expect(destroy.channel.deleted_at).toBeTruthy(); - expect(await client.queryChannels({ cid: channel.cid })).toEqual([]); + expect(destroy.channel?.cid).toBe(channel.cid); + expect(destroy.channel?.deleted_at).toBeTruthy(); + expect( + await client.queryChannelsAndHydrate({ filter_conditions: { cid: channel.cid } }), + ).toEqual([]); }); it('deletes many channels (soft and hard)', async () => { @@ -54,19 +58,24 @@ describe('_default/03-channels/07-channel_management/13-deleting.md', () => { // #region snippet docs="_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting Many Channels" tab="JavaScript" index=1 // soft delete - const response = await client.deleteChannels([cid1, cid2]); + const response = await client.deleteChannels({ cids: [cid1, cid2] }); // hard delete - const hardResponse = await client.deleteChannels([cid1, cid2], { + const hardResponse = await client.deleteChannels({ + cids: [cid1, cid2], hard_delete: true, }); const result = hardResponse.result; // holds deletion result // #endregion snippet // client-side the response has a per-cid `result` and no `task_id` - expect(Object.keys(response.result).sort()).toEqual([cid1, cid2].sort()); - expect(Object.keys(result).sort()).toEqual([cid1, cid2].sort()); + expect(Object.keys(response.result ?? {}).sort()).toEqual([cid1, cid2].sort()); + expect(Object.keys(result ?? {}).sort()).toEqual([cid1, cid2].sort()); expect(hardResponse.task_id).toBeUndefined(); - expect(await client.queryChannels({ cid: { $in: [cid1, cid2] } })).toEqual([]); + expect( + await client.queryChannelsAndHydrate({ + filter_conditions: { cid: { $in: [cid1, cid2] } }, + }), + ).toEqual([]); }); }); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts b/test/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts index 922460b94f..65d803681f 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts +++ b/test/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts @@ -25,7 +25,9 @@ describe('_default/03-channels/07-channel_management/14-freezing.md', () => { client = await getClientSideClient({ id: userId }); const id = uniqueId('general'); cleanup.channels.push(`messaging:${id}`); - channel = client.channel('messaging', id, { members: [userId, otherId] }); + channel = client.channel('messaging', id, { + members: [{ user_id: userId }, { user_id: otherId }], + }); await channel.create(); await serverClient.chat.channel('messaging', id).update({ add_moderators: [userId] }); }); @@ -42,36 +44,36 @@ describe('_default/03-channels/07-channel_management/14-freezing.md', () => { }); // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Freeze a Channel" tab="JavaScript" index=1 - const update = await channel.update( - { frozen: true }, - { text: 'Thierry has frozen the channel' }, - ); + const update = await channel.update({ + data: { frozen: true }, + message: { text: 'Thierry has frozen the channel' }, + }); const partialUpdate = await channel.updatePartial({ set: { frozen: true } }); // #endregion snippet - expect(update.channel.frozen).toBe(true); + expect(update.channel?.frozen).toBe(true); expect(update.message?.text).toBe('Thierry has frozen the channel'); - expect(partialUpdate.channel.frozen).toBe(true); + expect(partialUpdate.channel?.frozen).toBe(true); // sending a message returns an error message, reacting fails with 403 - const sent = await channel.sendMessage({ text: 'while frozen' }); + const sent = await channel.sendMessage({ message: { text: 'while frozen' } }); expect(sent.message.type).toBe('error'); - await expect(channel.sendReaction(otherMessage.id, { type: 'like' })).rejects.toThrow( - /frozen/, - ); + await expect( + channel.sendReaction({ id: otherMessage.id }, { reaction: { type: 'like' } }), + ).rejects.toThrow(/frozen/); }); it('unfreezes a channel', async () => { // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Unfreeze a Channel" tab="JavaScript" index=1 - const update = await channel.update( - { frozen: false }, - { text: 'Thierry has unfrozen the channel' }, - ); + const update = await channel.update({ + data: { frozen: false }, + message: { text: 'Thierry has unfrozen the channel' }, + }); // #endregion snippet - expect(update.channel.frozen).toBe(false); - const sent = await channel.sendMessage({ text: 'after unfreezing' }); + expect(update.channel?.frozen).toBe(false); + const sent = await channel.sendMessage({ message: { text: 'after unfreezing' } }); expect(sent.message.type).toBe('regular'); }); }); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts b/test/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts index 8deb82a31a..8a26fa6a55 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts +++ b/test/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts @@ -22,7 +22,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { const id = uniqueId('general'); cleanup.channels.push(`messaging:${id}`); const channel = client.channel('messaging', id, { - members: [userId, janeId, johnId], + members: [{ user_id: userId }, { user_id: janeId }, { user_id: johnId }], }); await channel.create(); return channel; @@ -30,12 +30,13 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { // The messages `viewer` gets back when it queries `channel`. const messageTexts = async (viewer: StreamChat, channel: Channel) => { - const [result] = await viewer.queryChannels( - { cid: channel.cid }, - {}, - { message_limit: 10, watch: false, state: true }, - ); - return result.state.messages.map((message) => message.text); + const { channels } = await viewer.queryChannels({ + filter_conditions: { cid: channel.cid }, + message_limit: 10, + watch: false, + state: true, + }); + return channels[0]?.messages.map((message) => message.text); }; beforeAll(async () => { @@ -52,7 +53,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { it('truncates a channel', async () => { const channel = await createChannel(); - await channel.sendMessage({ text: 'first' }); + await channel.sendMessage({ message: { text: 'first' } }); // #region snippet docs="_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate a Channel" tab="JavaScript" index=1 await channel.truncate(); @@ -68,7 +69,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { // #endregion snippet const { channel: data } = await channel.query({ messages: { limit: 10 } }); - expect(data.truncated_at).toBeDefined(); + expect(data?.truncated_at).toBeDefined(); expect(await messageTexts(client, channel)).toEqual([ 'Dear Everyone. The channel has been truncated.', ]); @@ -76,7 +77,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { it('truncates a channel for specific members', async () => { const channel = await createChannel(); - await channel.sendMessage({ text: 'history' }); + await channel.sendMessage({ message: { text: 'history' } }); // #region snippet docs="_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate for Specific Members" tab="JavaScript" index=1 // COPY: janeId="jane", johnId="john" diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts b/test/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts index e18c6786dd..892ebe7a12 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts +++ b/test/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts @@ -56,7 +56,9 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () it('invites users', async () => { const id = uniqueId('general'); cleanup.channels.push(`messaging:${id}`); - const channel = ownerClient.channel('messaging', id, { members: [ownerId] }); + const channel = ownerClient.channel('messaging', id, { + members: [{ user_id: ownerId }], + }); await channel.create(); // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Invite Users" tab="JavaScript" index=1 @@ -107,8 +109,8 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () it('queries accepted invites', async () => { // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Accepted Invites" tab="JavaScript" index=1 - const invites = await client.queryChannels({ - invite: 'accepted', + const invites = await client.queryChannelsAndHydrate({ + filter_conditions: { invite: 'accepted' }, }); // #endregion snippet @@ -120,8 +122,8 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () it('queries rejected invites', async () => { // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Rejected Invites" tab="JavaScript" index=1 - const rejected = await client.queryChannels({ - invite: 'rejected', + const rejected = await client.queryChannelsAndHydrate({ + filter_conditions: { invite: 'rejected' }, }); // #endregion snippet @@ -133,8 +135,8 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () it('queries pending invites', async () => { // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Pending Invites" tab="JavaScript" index=1 - const pending = await client.queryChannels({ - invite: 'pending', + const pending = await client.queryChannelsAndHydrate({ + filter_conditions: { invite: 'pending' }, }); // #endregion snippet diff --git a/test/docs-snippets/pages/04-messages/01-send_message.test.ts b/test/docs-snippets/pages/04-messages/01-send_message.test.ts index 4075106139..a5e8e69605 100644 --- a/test/docs-snippets/pages/04-messages/01-send_message.test.ts +++ b/test/docs-snippets/pages/04-messages/01-send_message.test.ts @@ -40,7 +40,9 @@ describe('_default/04-messages/01-send_message.md', () => { await waitForChannelTypePropagation(); client = await getClientSideClient({ id: userId }); - channel = client.channel(channelType, channelId, { members: [userId, joshId] }); + channel = client.channel(channelType, channelId, { + members: [{ user_id: userId }, { user_id: joshId }], + }); cleanup.channels.push(`${channelType}:${channelId}`); await channel.watch(); }); @@ -53,7 +55,7 @@ describe('_default/04-messages/01-send_message.md', () => { it('sends a message', async () => { // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 const message = await channel.sendMessage({ - text: 'Hello, world!', + message: { text: 'Hello, world!' }, }); // #endregion snippet @@ -74,26 +76,28 @@ describe('_default/04-messages/01-send_message.md', () => { // COPY: designTeamId="design-team-id" // Send a message mentioning all online users const message = await channel.sendMessage({ - text: 'Hey everyone!', - mentioned_here: true, + message: { text: 'Hey everyone!', mentioned_here: true }, }); // Send a message mentioning all channel members const allMessage = await channel.sendMessage({ - text: 'Important announcement!', - mentioned_channel: true, + message: { text: 'Important announcement!', mentioned_channel: true }, }); // Send a message mentioning specific roles const roleMessage = await channel.sendMessage({ - text: 'Attention moderators!', - mentioned_roles: ['channel_moderator', 'admin'], + message: { + text: 'Attention moderators!', + mentioned_roles: ['channel_moderator', 'admin'], + }, }); // Send a message mentioning a user group const groupMessage = await channel.sendMessage({ - text: 'Design team, please review!', - mentioned_group_ids: [designTeamId], + message: { + text: 'Design team, please review!', + mentioned_group_ids: [designTeamId], + }, }); // #endregion snippet @@ -108,37 +112,38 @@ describe('_default/04-messages/01-send_message.md', () => { const josh = { id: joshId }; // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending Messages with Attachments" tab="JavaScript" index=1 - const message = await channel.sendMessage( - { + // Custom fields go under `custom`, on the message and on each attachment. + const message = await channel.sendMessage({ + message: { text: '@Josh Check out this image!', attachments: [ { type: 'image', asset_url: 'https://bit.ly/2K74TaG', thumb_url: 'https://bit.ly/2Uumxti', - myCustomField: 123, + custom: { myCustomField: 123 }, }, ], mentioned_users: [josh.id], - priority: 'high', + custom: { priority: 'high' }, }, - { skip_push: true }, - ); + skip_push: true, + }); // #endregion snippet expect(message.message.attachments?.[0]).toMatchObject({ type: 'image', asset_url: 'https://bit.ly/2K74TaG', - myCustomField: 123, + custom: { myCustomField: 123 }, }); expect(message.message.mentioned_users?.map((u) => u.id)).toEqual([joshId]); - expect(message.message.priority).toBe('high'); + expect(message.message.custom.priority).toBe('high'); }); it('enriches URLs', async () => { // #region snippet docs="_default/04-messages/01-send_message.md" heading="URL Enrichment" tab="JavaScript" index=1 const response = await channel.sendMessage({ - text: 'Check this out https://imgur.com/r/bears/4zmGbMN', + message: { text: 'Check this out https://imgur.com/r/bears/4zmGbMN' }, }); // #endregion snippet @@ -152,7 +157,7 @@ describe('_default/04-messages/01-send_message.md', () => { ).id; // #region snippet docs="_default/04-messages/01-send_message.md" heading="Retrieving a Message" tab="JavaScript" index=1 - const message = await client.getMessage(messageID); + const message = await client.getMessage({ id: messageID }); // #endregion snippet expect(message.message.id).toBe(messageID); @@ -168,8 +173,10 @@ describe('_default/04-messages/01-send_message.md', () => { ).id; // #region snippet docs="_default/04-messages/01-send_message.md" heading="Updating a Message" tab="JavaScript" index=1 - const message = { id: messageId, text: 'Updated message text' }; - const updated = await client.updateMessage(message); + const updated = await client.updateMessage( + { id: messageId }, + { message: { text: 'Updated message text' } }, + ); // #endregion snippet expect(updated.message.text).toBe('Updated message text'); @@ -177,32 +184,33 @@ describe('_default/04-messages/01-send_message.md', () => { it('partially updates a message', async () => { const { message: originalMessage } = await channel.sendMessage({ - text: 'original', - color: 'red', - details: { status: 'pending' }, + message: { + text: 'original', + custom: { color: 'red', details: { status: 'pending' } }, + }, }); // #region snippet docs="_default/04-messages/01-send_message.md" heading="Partial Update" tab="JavaScript" index=1 // Update text - await client.partialUpdateMessage(originalMessage.id, { - set: { text: 'Updated text' }, - }); + await client.updateMessagePartial( + { id: originalMessage.id }, + { set: { text: 'Updated text' } }, + ); // Remove a custom field - await client.partialUpdateMessage(originalMessage.id, { - unset: ['color'], - }); + await client.updateMessagePartial({ id: originalMessage.id }, { unset: ['color'] }); // Update nested properties - await client.partialUpdateMessage(originalMessage.id, { - set: { 'details.status': 'complete' }, - }); + await client.updateMessagePartial( + { id: originalMessage.id }, + { set: { 'details.status': 'complete' } }, + ); // #endregion snippet - const { message } = await client.getMessage(originalMessage.id); + const { message } = await client.getMessage({ id: originalMessage.id }); expect(message.text).toBe('Updated text'); - expect(message.color).toBeUndefined(); - expect(message.details).toEqual({ status: 'complete' }); + expect(message.custom.color).toBeUndefined(); + expect(message.custom.details).toEqual({ status: 'complete' }); }); it('deletes a message', async () => { @@ -218,13 +226,13 @@ describe('_default/04-messages/01-send_message.md', () => { // #region snippet docs="_default/04-messages/01-send_message.md" heading="Deleting a Message" tab="JavaScript" index=1 // Soft delete - await client.deleteMessage(messageID); + await client.deleteMessage({ id: messageID }); // Hard delete - await client.deleteMessage(messageID, { hardDelete: true }); + await client.deleteMessage({ id: messageID }, { hard: true }); // Delete for me - await client.deleteMessage(anotherMessageID, { deleteForMe: true }); + await client.deleteMessage({ id: anotherMessageID }, { delete_for_me: true }); // #endregion snippet await expect(serverClient.chat.getMessage({ id: messageID })).rejects.toMatchObject({ @@ -235,7 +243,7 @@ describe('_default/04-messages/01-send_message.md', () => { id: anotherMessageID, }); expect(forOthers.type).not.toBe('deleted'); - const { message: forMe } = await client.getMessage(anotherMessageID); + const { message: forMe } = await client.getMessage({ id: anotherMessageID }); expect(forMe.deleted_for_me).toBe(true); }); }); diff --git a/test/docs-snippets/pages/04-messages/02-file_uploads.test.ts b/test/docs-snippets/pages/04-messages/02-file_uploads.test.ts index d1f1c9b65b..5753bfc4a5 100644 --- a/test/docs-snippets/pages/04-messages/02-file_uploads.test.ts +++ b/test/docs-snippets/pages/04-messages/02-file_uploads.test.ts @@ -1,17 +1,8 @@ -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { Channel, FileLike, FileReference, StreamChat } from '../../../../src'; import { - createUserToken, disconnectClients, + getClientSideClient, getServerClient, } from '../../helpers/clients'; import { Cleanup } from '../../helpers/cleanup'; @@ -20,27 +11,8 @@ import { uniqueId } from '../../helpers/ids'; import { getServerUser } from '../../helpers/server'; import { waitForChannelTypePropagation } from '../../helpers/wait'; -// The snippets upload browser `File`s. Under Node the SDK builds the multipart body with the -// `form-data` package (which only takes streams and Buffers) and only detects `File`s when -// `window` exists. Emulate the browser bundle: native `FormData` (what `form-data` resolves to in -// browsers) and a `window` with `File`/`Blob`, stubbed only while each test runs. -vi.mock('form-data', () => ({ default: globalThis.FormData })); - -/** - * Like `getClientSideClient`, but from a fresh import of src: `setup.ts` already loaded src (and - * the real `form-data`) before the mock above was registered. - */ -const getBrowserLikeClientSideClient = async (userId: string): Promise => { - const serverClient = getServerClient(); - await serverClient.upsertUsers([{ id: userId }]); - vi.resetModules(); - const { StreamChat: FreshStreamChat } = await import('../../../../src'); - const client = new FreshStreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, - }); - await client.connectUser({ id: userId }, createUserToken(userId)); - return client; -}; +// v10 builds multipart bodies with the global `FormData`, and Node's `File` is a `Blob`, so the +// snippets' browser `File`s upload as-is under Node. // 1x1 transparent PNG. const PNG_BYTES = Buffer.from( @@ -52,6 +24,12 @@ const makeImage = (name: string) => new File([PNG_BYTES], name, { type: 'image/p const makeTextFile = (name: string) => new File(['hello from the docs tests'], name, { type: 'text/plain' }); +/** The upload responses type `file` as optional; it is always set on success. */ +const uploadedUrl = ({ file }: { file?: string }) => { + if (!file) throw new Error('upload returned no file URL'); + return file; +}; + describe('_default/04-messages/02-file_uploads.md', () => { const serverClient = getServerClient(); const cleanup = new Cleanup(serverClient); @@ -67,20 +45,12 @@ describe('_default/04-messages/02-file_uploads.md', () => { await grantMessagingMembers(serverClient, cleanup, ['create-attachment']); await waitForChannelTypePropagation(); - client = await getBrowserLikeClientSideClient(userId); - channel = client.channel(channelType, channelId, { members: [userId] }); + client = await getClientSideClient({ id: userId }); + channel = client.channel(channelType, channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`${channelType}:${channelId}`); await channel.watch(); }); - beforeEach(() => { - vi.stubGlobal('window', { File, Blob }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - afterAll(async () => { await disconnectClients(client); await cleanup.run(); @@ -92,11 +62,8 @@ describe('_default/04-messages/02-file_uploads.md', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Uploading Files to a Channel" tab="JavaScript" index=1 - const response1 = await channel.sendImage( - file1, - file1.name, - file1.type, - undefined, + const response1 = await channel.uploadImage( + { file: file1 }, // Optional { onUploadProgress: (e) => { @@ -106,11 +73,8 @@ describe('_default/04-messages/02-file_uploads.md', () => { }, ); - const response2 = await channel.sendFile( - file2, - file2.name, - file2.type, - undefined, + const response2 = await channel.uploadFile( + { file: file2 }, // Optional { onUploadProgress: (e) => { @@ -121,19 +85,23 @@ describe('_default/04-messages/02-file_uploads.md', () => { ); await channel.sendMessage({ - text: 'Check these out', - attachments: [ - { - type: 'image', - asset_url: response1.file, - thumb_url: response1.file, - }, - { - type: 'file', - asset_url: response2.file, - title: file2.name, - }, - ], + message: { + text: 'Check these out', + attachments: [ + { + type: 'image', + asset_url: response1.file, + thumb_url: response1.file, + custom: {}, + }, + { + type: 'file', + asset_url: response2.file, + title: file2.name, + custom: {}, + }, + ], + }, }); // #endregion snippet @@ -147,8 +115,8 @@ describe('_default/04-messages/02-file_uploads.md', () => { const { messages } = await channel.query({ messages: { limit: 1 } }); const [message] = messages; expect(message.text).toBe('Check these out'); - expect(message.attachments?.map((a) => a.type)).toEqual(['image', 'file']); - expect(message.attachments?.[1].title).toBe('notes.txt'); + expect(message.attachments.map((a) => a.type)).toEqual(['image', 'file']); + expect(message.attachments[1].title).toBe('notes.txt'); }); it('uploads a standalone image and uses it as the user avatar', async () => { @@ -157,10 +125,7 @@ describe('_default/04-messages/02-file_uploads.md', () => { // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Uploading Standalone Files" tab="JavaScript" index=1 const { file: imageUrl } = await client.uploadImage( - imageFile, - imageFile.name, - imageFile.type, - undefined, + { file: imageFile }, // Optional { onUploadProgress: (e) => { @@ -171,28 +136,32 @@ describe('_default/04-messages/02-file_uploads.md', () => { ); // Example: use the image URL as a user avatar - await client.partialUpdateUser({ - id: userId, - set: { image: imageUrl }, + await client.updateUsersPartial({ + users: [{ id: userId, set: { image: imageUrl } }], }); // #endregion snippet // Standalone uploads aren't tied to the channel or removed with the user. - await client.deleteImage(imageUrl); + const uploadedImageUrl = uploadedUrl({ file: imageUrl }); + await client.deleteImage({ url: uploadedImageUrl }); const logs = logSpy.mock.calls.map(([line]) => String(line)); logSpy.mockRestore(); - expect(imageUrl).toMatch(/^https:\/\//); + expect(uploadedImageUrl).toMatch(/^https:\/\//); expect(logs.some((line) => /^Image: \d+%$/.test(line))).toBe(true); const { image } = await getServerUser(serverClient, userId); expect(typeof image).toBe('string'); // The stored URL is re-signed on read, so compare without the query string. - expect(String(image).split('?')[0]).toBe(imageUrl.split('?')[0]); + expect(String(image).split('?')[0]).toBe(uploadedImageUrl.split('?')[0]); }); it('deletes files from a channel', async () => { - const { file: fileURL } = await channel.sendFile(makeTextFile('delete-me.txt')); - const { file: imageURL } = await channel.sendImage(makeImage('delete-me.png')); + const fileURL = uploadedUrl( + await channel.uploadFile({ file: makeTextFile('delete-me.txt') }), + ); + const imageURL = uploadedUrl( + await channel.uploadImage({ file: makeImage('delete-me.png') }), + ); const deleteFileSpy = vi.spyOn(channel, 'deleteFile'); const deleteImageSpy = vi.spyOn(channel, 'deleteImage'); @@ -224,19 +193,37 @@ describe('_default/04-messages/02-file_uploads.md', () => { return Promise.resolve({ url: 'https://cdn.example.com/uploads/photo.png' }); }, }; + // A composer receives configuration changes once its subscriptions are registered (UI SDKs + // do this when they mount it); composers created later get it on construction. const messageComposer = channel.messageComposer; - - // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Using Your Own CDN" tab="JavaScript" index=1 - messageComposer.attachmentManager.setCustomUploadFn(async (file) => { - const result = await customCDN.upload(file); - return { file: result.url }; - }); - // #endregion snippet - - expect(messageComposer.attachmentManager.hasCustomDoUploadRequest).toBe(true); - const file = makeImage('photo.png'); - const result = await messageComposer.attachmentManager.doUploadRequest(file); - expect(result).toEqual({ file: 'https://cdn.example.com/uploads/photo.png' }); - expect(uploaded).toEqual([file]); + messageComposer.registerSubscriptions(); + + try { + // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Using Your Own CDN" tab="JavaScript" index=1 + client.config.set({ + messageComposer: { + attachments: { + // Uploads go to storage Stream doesn't host + customCdn: true, + doUploadRequest: async (file) => { + const result = await customCDN.upload(file); + return { file: result.url }; + }, + }, + }, + }); + // #endregion snippet + + const { attachmentManager } = messageComposer; + expect(attachmentManager.hasCustomDoUploadRequest).toBe(true); + expect(attachmentManager.config.customCdn).toBe(true); + const file = makeImage('photo.png'); + const result = await attachmentManager.doUploadRequest(file); + expect(result).toEqual({ file: 'https://cdn.example.com/uploads/photo.png' }); + expect(uploaded).toEqual([file]); + } finally { + client.config.reset('messageComposer'); + messageComposer.unregisterSubscriptions(); + } }); }); diff --git a/test/docs-snippets/pages/04-messages/03-threads.test.ts b/test/docs-snippets/pages/04-messages/03-threads.test.ts index 4f54e2f23b..dc15cd5aed 100644 --- a/test/docs-snippets/pages/04-messages/03-threads.test.ts +++ b/test/docs-snippets/pages/04-messages/03-threads.test.ts @@ -34,7 +34,7 @@ describe('_default/04-messages/03-threads.md', () => { const ownThreadIds: string[] = []; const threadUnreadCount = async (id: string) => { - const { threads } = await client.getUnreadCount(); + const { threads } = await client.unreadCounts(); return threads.find((thread) => thread.parent_message_id === id)?.unread_count ?? 0; }; @@ -42,11 +42,15 @@ describe('_default/04-messages/03-threads.md', () => { cleanup.users.push(userId, otherId); await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); - channel = client.channel('messaging', channelId, { members: [userId, otherId] }); + channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); - const { message } = await channel.sendMessage({ text: 'Parent message' }); + const { message } = await channel.sendMessage({ + message: { text: 'Parent message' }, + }); parentMessageId = message.id; threadIds.push(parentMessageId); @@ -84,9 +88,11 @@ describe('_default/04-messages/03-threads.md', () => { it('starts a thread', async () => { // #region snippet docs="_default/04-messages/03-threads.md" heading="Starting a Thread" tab="JavaScript" index=1 const reply = await channel.sendMessage({ - text: 'This is a reply in a thread', - parent_id: parentMessageId, - show_in_channel: false, + message: { + text: 'This is a reply in a thread', + parent_id: parentMessageId, + show_in_channel: false, + }, }); // #endregion snippet @@ -100,18 +106,22 @@ describe('_default/04-messages/03-threads.md', () => { // #region snippet docs="_default/04-messages/03-threads.md" heading="Paginating Thread Replies" tab="JavaScript" index=1 // COPY: olderReplyId="42" // Get the latest 20 replies - const replies = await channel.getReplies(parentMessageId, { limit: 20 }); + const replies = await client.getReplies( + { parent_id: parentMessageId }, + { limit: 20 }, + ); // Get older replies (before message with ID "42") - const olderReplies = await channel.getReplies(parentMessageId, { - limit: 20, - id_lte: olderReplyId, - }); + const olderReplies = await client.getReplies( + { parent_id: parentMessageId }, + { limit: 20, id_lte: olderReplyId }, + ); // Get oldest replies first - const oldestFirst = await channel.getReplies(parentMessageId, { limit: 20 }, [ - { created_at: 1 }, - ]); + const oldestFirst = await client.getReplies( + { parent_id: parentMessageId }, + { limit: 20, sort: [{ field: 'created_at', direction: 1 }] }, + ); // #endregion snippet const replyMessageIds = replies.messages.map((m) => m.id); @@ -125,8 +135,10 @@ describe('_default/04-messages/03-threads.md', () => { // #region snippet docs="_default/04-messages/03-threads.md" heading="Inline Replies" tab="JavaScript" index=1 const message = await channel.sendMessage({ - text: 'I agree with this point', - quoted_message_id: originalMessageId, + message: { + text: 'I agree with this point', + quoted_message_id: originalMessageId, + }, }); // #endregion snippet @@ -136,12 +148,12 @@ describe('_default/04-messages/03-threads.md', () => { it('queries threads', async () => { // #region snippet docs="_default/04-messages/03-threads.md" heading="Querying Threads" tab="JavaScript" index=1 - const { threads } = await client.queryThreads(); + const { threads } = await client.queryThreadsAndHydrate(); for (const thread of threads) { const state = thread.state.getLatestValue(); console.log(state.parentMessage.text); - console.log(state.replies); + console.log(thread.messagePaginator.items); // the latest replies console.log(state.participants); console.log(state.read); } @@ -157,22 +169,22 @@ describe('_default/04-messages/03-threads.md', () => { // #region snippet docs="_default/04-messages/03-threads.md" heading="Supported Sort Fields" tab="JavaScript" index=1 // COPY: createdByUserId="user-1" // Get threads created by a specific user, sorted by creation date - const { threads, next } = await client.queryThreads({ + const { threads, next } = await client.queryThreadsAndHydrate({ filter: { created_by_user_id: { $eq: createdByUserId }, updated_at: { $gte: '2024-01-01T00:00:00Z' }, }, - sort: [{ created_at: -1 }], + sort: [{ field: 'created_at', direction: -1 }], limit: 10, }); // Get next page - const { threads: page2 } = await client.queryThreads({ + const { threads: page2 } = await client.queryThreadsAndHydrate({ filter: { created_by_user_id: { $eq: createdByUserId }, updated_at: { $gte: '2024-01-01T00:00:00Z' }, }, - sort: [{ created_at: -1 }], + sort: [{ field: 'created_at', direction: -1 }], limit: 10, next, }); @@ -187,7 +199,7 @@ describe('_default/04-messages/03-threads.md', () => { it('gets a thread by id', async () => { // #region snippet docs="_default/04-messages/03-threads.md" heading="Getting a Thread by ID" tab="JavaScript" index=1 - const thread = await client.getThread(parentMessageId, { + const thread = await client.getThreadAndHydrate(parentMessageId, { watch: true, reply_limit: 10, participant_limit: 25, @@ -195,7 +207,8 @@ describe('_default/04-messages/03-threads.md', () => { // #endregion snippet expect(thread.id).toBe(parentMessageId); - const { replies, participants } = thread.state.getLatestValue(); + const { participants } = thread.state.getLatestValue(); + const replies = thread.messagePaginator.items ?? []; expect(replies.length).toBeGreaterThan(0); expect(replies.length).toBeLessThanOrEqual(10); expect(participants?.map((p) => p.user_id)).toEqual( @@ -208,24 +221,23 @@ describe('_default/04-messages/03-threads.md', () => { // #region snippet docs="_default/04-messages/03-threads.md" heading="Updating Thread Title and Custom Data" tab="JavaScript" index=1 // Set properties - const { thread } = await client.partialUpdateThread(threadId, { - set: { - title: 'Project Discussion', - priority: 'high', + const { thread } = await client.updateThreadPartial( + { message_id: threadId }, + { + set: { + title: 'Project Discussion', + priority: 'high', + }, }, - }); + ); // Remove properties - await client.partialUpdateThread(threadId, { - unset: ['priority'], - }); + await client.updateThreadPartial({ message_id: threadId }, { unset: ['priority'] }); // #endregion snippet expect(thread.title).toBe('Project Discussion'); - expect(thread).toMatchObject({ priority: 'high' }); - const updated = ( - await client.getThread(threadId, { watch: false }) - ).state.getLatestValue(); + expect(thread.custom).toMatchObject({ priority: 'high' }); + const { thread: updated } = await client.getThread({ message_id: threadId }); expect(updated.title).toBe('Project Discussion'); expect(updated.custom).not.toHaveProperty('priority'); }); @@ -303,7 +315,7 @@ describe('_default/04-messages/03-threads.md', () => { it('gets the unread count per thread', async () => { // #region snippet docs="_default/04-messages/03-threads.md" heading="Unread Count Per Thread" tab="JavaScript" index=1 - const response = await client.getUnreadCount(); + const response = await client.unreadCounts(); console.log(response.total_unread_threads_count); @@ -325,34 +337,43 @@ describe('_default/04-messages/03-threads.md', () => { // Subscribe to state updates const unsubscribe = threadManager.state.subscribe((state) => { - console.log(state.threads); console.log(state.unreadThreadCount); }); + // Subscribe to the thread list + const unsubscribeList = threadManager.paginator.state.subscribe((state) => { + console.log(state.items); + }); + // Load threads await threadManager.reload(); // Load more threads - await threadManager.loadNextPage(); + await threadManager.paginator.toTail(); // Access current state - const { threads } = threadManager.state.getLatestValue(); + const { items: threads = [] } = threadManager.paginator.state.getLatestValue(); // #endregion snippet unsubscribe(); + unsubscribeList(); + expect(threadManager.paginator.lastQueryError).toBeUndefined(); expect(threads.map((thread) => thread.id).sort()).toEqual([...threadIds].sort()); }); it('handles events for a single thread', async () => { // #region snippet docs="_default/04-messages/03-threads.md" heading="Event Handling" tab="JavaScript" index=1 - const { threads } = await client.queryThreads({ watch: true, limit: 10 }); + const { threads } = await client.queryThreadsAndHydrate({ watch: true, limit: 10 }); const [thread] = threads; - // Register event handlers for a single thread - thread.registerSubscriptions(); + // Threads receive events through the client's thread manager + client.threads.registerSubscriptions(); - const unsubscribe = thread.state.subscribe((state) => { - console.log(state.replies); + // Open the thread: the thread manager keeps it up to date from now on + const release = thread.activate(); + + const unsubscribe = thread.messagePaginator.state.subscribe((state) => { + console.log(state.items); }); // #endregion snippet @@ -363,14 +384,15 @@ describe('_default/04-messages/03-threads.md', () => { parent_id: thread.id, }); await retry(() => { - expect(thread.state.getLatestValue().replies.map((r) => r.id)).toContain( + expect((thread.messagePaginator.items ?? []).map((r) => r.id)).toContain( reply.id, ); return Promise.resolve(); }); } finally { unsubscribe(); - thread.unregisterSubscriptions(); + release(); + client.threads.unregisterSubscriptions(); } }); @@ -385,19 +407,19 @@ describe('_default/04-messages/03-threads.md', () => { await threadManager.reload(); - // All threads are now listening to channel events - const { threads } = threadManager.state.getLatestValue(); + // All loaded threads now receive their events + const { items: threads = [] } = threadManager.paginator.state.getLatestValue(); // #endregion snippet expect(threads.map((thread) => thread.id)).toContain(parentMessageId); - const thread = threadManager.threadsById[parentMessageId]; + const thread = threadManager.get(parentMessageId); const reply = await sendServerMessage(serverClient, cid, { text: 'Managed reply', user_id: otherId, parent_id: parentMessageId, }); await retry(() => { - expect(thread?.state.getLatestValue().replies.map((r) => r.id)).toContain( + expect((thread?.messagePaginator.items ?? []).map((r) => r.id)).toContain( reply.id, ); return Promise.resolve(); diff --git a/test/docs-snippets/pages/04-messages/04-send_reaction.test.ts b/test/docs-snippets/pages/04-messages/04-send_reaction.test.ts index 9a1de7428e..e9a8a05d8c 100644 --- a/test/docs-snippets/pages/04-messages/04-send_reaction.test.ts +++ b/test/docs-snippets/pages/04-messages/04-send_reaction.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, QueryReactionsAPIResponse, StreamChat } from '../../../../src'; +import type { Channel, QueryReactionsResponse, StreamChat } from '../../../../src'; import { disconnectClients, getClientSideClient, @@ -18,7 +18,7 @@ describe('_default/04-messages/04-send_reaction.md', () => { let channel: Channel; const newMessageId = async (text: string) => { - const { message } = await channel.sendMessage({ text }); + const { message } = await channel.sendMessage({ message: { text } }); return message.id; }; @@ -43,7 +43,9 @@ describe('_default/04-messages/04-send_reaction.md', () => { cleanup.users.push(userId, otherId); await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); - channel = client.channel('messaging', channelId, { members: [userId, otherId] }); + channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); }); @@ -56,31 +58,31 @@ describe('_default/04-messages/04-send_reaction.md', () => { it('sends a reaction', async () => { const messageID = await newMessageId('React to me'); // A reaction of another type, which `enforce_unique` should replace. - await channel.sendReaction(messageID, { type: 'fire' }); + await channel.sendReaction({ id: messageID }, { reaction: { type: 'fire' } }); // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Sending a Reaction" tab="JavaScript" index=1 // Add a reaction - const reaction = await channel.sendReaction(messageID, { - type: 'love', - }); + const reaction = await channel.sendReaction( + { id: messageID }, + { reaction: { type: 'love' } }, + ); // Add a reaction with custom data - const customReaction = await channel.sendReaction(messageID, { - type: 'love', - customField: 'value', - }); + const customReaction = await channel.sendReaction( + { id: messageID }, + { reaction: { type: 'love', custom: { customField: 'value' } } }, + ); // Replace all existing reactions from this user with the new one const uniqueReaction = await channel.sendReaction( - messageID, - { type: 'love' }, - { enforce_unique: true }, + { id: messageID }, + { reaction: { type: 'love' }, enforce_unique: true }, ); // #endregion snippet expect(reaction.reaction.type).toBe('love'); expect(reaction.reaction.user_id).toBe(userId); - expect(customReaction.reaction.customField).toBe('value'); + expect(customReaction.reaction.custom.customField).toBe('value'); expect(uniqueReaction.reaction.type).toBe('love'); const message = await getServerMessage(messageID); @@ -89,11 +91,11 @@ describe('_default/04-messages/04-send_reaction.md', () => { it('removes a reaction', async () => { const messageID = await newMessageId('Remove my reaction'); - await channel.sendReaction(messageID, { type: 'love' }); + await channel.sendReaction({ id: messageID }, { reaction: { type: 'love' } }); expect((await getServerMessage(messageID)).reaction_counts).toEqual({ love: 1 }); // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Removing a Reaction" tab="JavaScript" index=1 - await channel.deleteReaction(messageID, 'love'); + await channel.deleteReaction({ id: messageID, type: 'love' }); // #endregion snippet const message = await getServerMessage(messageID); @@ -108,13 +110,13 @@ describe('_default/04-messages/04-send_reaction.md', () => { // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Paginating Reactions" tab="JavaScript" index=1 // Get the first 10 reactions - const response = await channel.getReactions(messageID, { limit: 10 }); + const response = await client.getReactions({ id: messageID }, { limit: 10 }); // Get reactions 11-13 - const nextResponse = await channel.getReactions(messageID, { - limit: 3, - offset: 10, - }); + const nextResponse = await client.getReactions( + { id: messageID }, + { limit: 3, offset: 10 }, + ); // #endregion snippet expect(response.reactions).toHaveLength(10); @@ -127,7 +129,7 @@ describe('_default/04-messages/04-send_reaction.md', () => { it('queries reactions', async () => { const messageId = await newMessageId('Query my reactions'); - await channel.sendReaction(messageId, { type: 'like' }); + await channel.sendReaction({ id: messageId }, { reaction: { type: 'like' } }); // Enough reactions from another user for `firstPage` to have a `next` cursor. const otherTypes = Array.from({ length: 30 }, (_, i) => `t${i + 1}`); await sendServerReactions(messageId, otherTypes, otherId); @@ -136,22 +138,20 @@ describe('_default/04-messages/04-send_reaction.md', () => { // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Querying Reactions" tab="JavaScript" index=1 // Query reactions by type - await client.queryReactions(message.id, { type: 'like' }); + await client.queryReactions({ id: message.id }, { filter: { type: 'like' } }); // Query reactions by user - await client.queryReactions(message.id, { user_id: userId }); + await client.queryReactions({ id: message.id }, { filter: { user_id: userId } }); // Paginate results - const firstPage = await client.queryReactions(message.id, {}); + const firstPage = await client.queryReactions({ id: message.id }); const secondPage = await client.queryReactions( - message.id, - {}, - {}, + { id: message.id }, { limit: 5, next: firstPage.next }, ); // #endregion snippet - const [byType, byUser]: QueryReactionsAPIResponse[] = await Promise.all( + const [byType, byUser]: QueryReactionsResponse[] = await Promise.all( spy.mock.results.slice(0, 2).map((r) => r.value), ); spy.mockRestore(); @@ -169,16 +169,16 @@ describe('_default/04-messages/04-send_reaction.md', () => { // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Cumulative Reactions" tab="JavaScript" index=1 // User claps 5 times - await channel.sendReaction(messageID, { - type: 'clap', - score: 5, - }); + await channel.sendReaction( + { id: messageID }, + { reaction: { type: 'clap', score: 5 } }, + ); // Same user claps 20 more times (total becomes 25) - await channel.sendReaction(messageID, { - type: 'clap', - score: 25, - }); + await channel.sendReaction( + { id: messageID }, + { reaction: { type: 'clap', score: 25 } }, + ); // #endregion snippet const message = await getServerMessage(messageID); diff --git a/test/docs-snippets/pages/04-messages/05-pinned_messages.test.ts b/test/docs-snippets/pages/04-messages/05-pinned_messages.test.ts index 6c8a0a47cb..e6b22d2a19 100644 --- a/test/docs-snippets/pages/04-messages/05-pinned_messages.test.ts +++ b/test/docs-snippets/pages/04-messages/05-pinned_messages.test.ts @@ -1,5 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, StreamChat, UpdateMessageAPIResponse } from '../../../../src'; +import { nsToDate } from '../../../../src'; +import type { + Channel, + StreamChat, + TimestampNS, + UpdateMessagePartialResponse, +} from '../../../../src'; import { disconnectClients, getClientSideClient, @@ -8,6 +14,10 @@ import { import { Cleanup } from '../../helpers/cleanup'; import { uniqueId } from '../../helpers/ids'; +/** Server timestamps are unix nanoseconds; '' when unset. */ +const isoString = (timestamp: TimestampNS | undefined) => + timestamp === undefined ? '' : nsToDate(timestamp).toISOString(); + describe('_default/04-messages/05-pinned_messages.md', () => { const serverClient = getServerClient(); const cleanup = new Cleanup(serverClient); @@ -23,16 +33,20 @@ describe('_default/04-messages/05-pinned_messages.md', () => { beforeAll(async () => { cleanup.users.push(userId); client = await getClientSideClient({ id: userId }); - channel = client.channel('messaging', channelId, { members: [userId] }); + channel = client.channel('messaging', channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); - pagingChannel = client.channel('messaging', pagingChannelId, { members: [userId] }); + pagingChannel = client.channel('messaging', pagingChannelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${pagingChannelId}`); await pagingChannel.create(); // 12 pinned messages, pinned one at a time so `pinned_at` follows this order. for (let i = 0; i < 12; i++) { - const { message } = await pagingChannel.sendMessage({ text: `Pinned ${i}` }); + const { message } = await pagingChannel.sendMessage({ + message: { text: `Pinned ${i}` }, + }); await client.pinMessage(message); pinnedIds.push(message.id); } @@ -51,9 +65,11 @@ describe('_default/04-messages/05-pinned_messages.md', () => { // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Pinning and Unpinning Messages" tab="JavaScript" index=1 // Create a pinned message const { message } = await channel.sendMessage({ - text: 'Important announcement', - pinned: true, - pin_expires: '2077-01-01T00:00:00Z', + message: { + text: 'Important announcement', + pinned: true, + pin_expires: new Date('2077-01-01T00:00:00Z'), + }, }); // Pin an existing message for 120 seconds @@ -70,37 +86,36 @@ describe('_default/04-messages/05-pinned_messages.md', () => { // #endregion snippet expect(message.pinned).toBe(true); - expect(message.pin_expires).toMatch(/^2077-01-01T00:00:00/); + expect(isoString(message.pin_expires)).toMatch(/^2077-01-01T00:00:00/); expect(message.pinned_by?.id).toBe(userId); - const [timed, dated, indefinite]: UpdateMessageAPIResponse[] = await Promise.all( + const [timed, dated, indefinite]: UpdateMessagePartialResponse[] = await Promise.all( pinSpy.mock.results.map((r) => r.value), ); pinSpy.mockRestore(); - expect(timed.message.pinned).toBe(true); - const timedExpires = new Date(timed.message.pin_expires ?? 0).getTime(); + expect(timed.message?.pinned).toBe(true); + const timedExpires = new Date(isoString(timed.message?.pin_expires)).getTime(); expect(timedExpires).toBeGreaterThanOrEqual(before + 110_000); expect(timedExpires).toBeLessThanOrEqual(Date.now() + 130_000); - expect(dated.message.pinned).toBe(true); - expect(dated.message.pin_expires).toMatch(/^2077-01-01T00:00:00/); - expect(indefinite.message.pinned).toBe(true); - expect(indefinite.message.pin_expires ?? null).toBeNull(); + expect(dated.message?.pinned).toBe(true); + expect(isoString(dated.message?.pin_expires)).toMatch(/^2077-01-01T00:00:00/); + expect(indefinite.message?.pinned).toBe(true); + expect(indefinite.message?.pin_expires ?? null).toBeNull(); - const [unpinned]: UpdateMessageAPIResponse[] = await Promise.all( + const [unpinned]: UpdateMessagePartialResponse[] = await Promise.all( unpinSpy.mock.results.map((r) => r.value), ); unpinSpy.mockRestore(); - expect(unpinned.message.pinned).toBe(false); + expect(unpinned.message?.pinned).toBe(false); const { message: stored } = await serverClient.chat.getMessage({ id: message.id }); expect(stored.pinned).toBe(false); }); it('retrieves pinned messages with the channel state', async () => { const { message: pinned } = await channel.sendMessage({ - text: 'Pinned for query', - pinned: true, + message: { text: 'Pinned for query', pinned: true }, }); - await channel.sendMessage({ text: 'Not pinned' }); + await channel.sendMessage({ message: { text: 'Not pinned' } }); // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Retrieving Pinned Messages" tab="JavaScript" index=1 const channelState = await client.channel('messaging', channelId).query(); @@ -117,14 +132,18 @@ describe('_default/04-messages/05-pinned_messages.md', () => { // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Paginating Pinned Messages" tab="JavaScript" index=1 // First page, newest first - const page1 = await channel.getPinnedMessages({ limit: 10 }, { pinned_at: -1 }); + const page1 = await channel.getPinnedMessages({ + limit: 10, + sort: [{ field: 'pinned_at', direction: -1 }], + }); // Next page const lastMsg = page1.messages[page1.messages.length - 1]; - const page2 = await channel.getPinnedMessages( - { limit: 10, id_lt: lastMsg.id }, - { pinned_at: -1 }, - ); + const page2 = await channel.getPinnedMessages({ + limit: 10, + id_lt: lastMsg.id, + sort: [{ field: 'pinned_at', direction: -1 }], + }); // Oldest first const ascPage = await channel.getPinnedMessages({ limit: 10 }); diff --git a/test/docs-snippets/pages/04-messages/06-search.test.ts b/test/docs-snippets/pages/04-messages/06-search.test.ts index 8b46110267..3a7c3f5d12 100644 --- a/test/docs-snippets/pages/04-messages/06-search.test.ts +++ b/test/docs-snippets/pages/04-messages/06-search.test.ts @@ -30,13 +30,19 @@ describe('_default/04-messages/06-search.md', () => { await serverClient.upsertUsers([{ id: otherUserId }]); client = await getClientSideClient({ id: userId }); - const channel = client.channel('messaging', searchChannelId, { members: [userId] }); + const channel = client.channel('messaging', searchChannelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${searchChannelId}`); await channel.create(); ids.text = ( - await channel.sendMessage({ text: 'supercalifragilisticexpialidocious' }) + await channel.sendMessage({ + message: { text: 'supercalifragilisticexpialidocious' }, + }) + ).message.id; + ids.plain = ( + await channel.sendMessage({ message: { text: 'superb, no attachment' } }) ).message.id; - ids.plain = (await channel.sendMessage({ text: 'superb, no attachment' })).message.id; // Sent server-side: client-side attachments need the `create-attachment` grant. ids.attachment = ( await sendServerMessage(serverClient, `messaging:${searchChannelId}`, { @@ -65,14 +71,13 @@ describe('_default/04-messages/06-search.md', () => { ).id; const pagingChannel = client.channel('messaging', pagingChannelId, { - members: [userId], + members: [{ user_id: userId }], }); cleanup.channels.push(myChannelCid); await pagingChannel.create(); for (let i = 0; i < PAGING_COUNT; i++) { const { message } = await pagingChannel.sendMessage({ - text: `supercali ${i}`, - my_custom_field: i, + message: { text: `supercali ${i}`, custom: { my_custom_field: i } }, }); pagingIds.push(message.id); } @@ -80,11 +85,18 @@ describe('_default/04-messages/06-search.md', () => { // Search indexing is eventually consistent: wait until every message is searchable. await retry(async () => { const [a, b] = await Promise.all([ - client.search( - { cid: `messaging:${searchChannelId}` }, - { text: { $autocomplete: 'super' } }, - ), - client.search({ cid: myChannelCid }, { text: { $autocomplete: 'supercali' } }), + client.search({ + payload: { + filter_conditions: { cid: `messaging:${searchChannelId}` }, + message_filter_conditions: { text: { $autocomplete: 'super' } }, + }, + }), + client.search({ + payload: { + filter_conditions: { cid: myChannelCid }, + message_filter_conditions: { text: { $autocomplete: 'supercali' } }, + }, + }), ]); expect(a.results).toHaveLength(3); expect(b.results).toHaveLength(PAGING_COUNT); @@ -100,22 +112,29 @@ describe('_default/04-messages/06-search.md', () => { // #region snippet docs="_default/04-messages/06-search.md" heading="Searching Messages" tab="JavaScript" index=1 // COPY: userId="john" // Search for messages containing text - const results = await client.search( - { members: { $in: [userId] } }, - 'supercalifragilisticexpialidocious', - { limit: 10 }, - ); + const results = await client.search({ + payload: { + filter_conditions: { members: { $in: [userId] } }, + query: 'supercalifragilisticexpialidocious', + limit: 10, + }, + }); // Search with message filters - const filtered = await client.search( - { members: { $in: [userId] } }, - { text: { $autocomplete: 'super' }, attachments: { $exists: true } }, - { limit: 10 }, - ); + const filtered = await client.search({ + payload: { + filter_conditions: { members: { $in: [userId] } }, + message_filter_conditions: { + text: { $autocomplete: 'super' }, + attachments: { $exists: true }, + }, + limit: 10, + }, + }); // #endregion snippet - expect(results.results.map((r) => r.message.id)).toEqual([ids.text]); - expect(filtered.results.map((r) => r.message.id)).toEqual([ids.attachment]); + expect(results.results.map((r) => r.message?.id)).toEqual([ids.text]); + expect(filtered.results.map((r) => r.message?.id)).toEqual([ids.attachment]); }); it('paginates search results with a cursor', async () => { @@ -125,25 +144,41 @@ describe('_default/04-messages/06-search.md', () => { const messageFilters = { text: { $autocomplete: 'supercali' } }; // First page with custom sorting - const page1 = await client.search(channelFilters, messageFilters, { - sort: [{ relevance: -1 }, { updated_at: 1 }, { my_custom_field: -1 }], - limit: 10, + const page1 = await client.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + sort: [ + { field: 'relevance', direction: -1 }, + { field: 'updated_at', direction: 1 }, + { field: 'my_custom_field', direction: -1 }, + ], + limit: 10, + }, }); // Next page using cursor - const page2 = await client.search(channelFilters, messageFilters, { - limit: 10, - next: page1.next, + const page2 = await client.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + limit: 10, + next: page1.next, + }, }); // Previous page - const page1Again = await client.search(channelFilters, messageFilters, { - limit: 10, - next: page2.previous, + const page1Again = await client.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + limit: 10, + next: page2.previous, + }, }); // #endregion snippet - const idsOf = (page: typeof page1) => page.results.map((r) => r.message.id); + const idsOf = (page: typeof page1) => page.results.map((r) => r.message?.id); expect(idsOf(page1)).toEqual(pagingIds.slice(0, 10)); expect(idsOf(page2)).toEqual(pagingIds.slice(10)); expect(page2.next).toBeUndefined(); diff --git a/test/docs-snippets/pages/04-messages/07-silent_messages.test.ts b/test/docs-snippets/pages/04-messages/07-silent_messages.test.ts index ee59aaf225..4c118f0193 100644 --- a/test/docs-snippets/pages/04-messages/07-silent_messages.test.ts +++ b/test/docs-snippets/pages/04-messages/07-silent_messages.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { Channel, StreamChat } from '../../../../src'; import { disconnectClients, @@ -38,7 +38,9 @@ describe('_default/04-messages/07-silent_messages.md', () => { await grantMessagingMembers(serverClient, cleanup, EXTRA_MEMBER_GRANTS); await waitForChannelTypePropagation(); client = await getClientSideClient({ id: userId }); - channel = client.channel(channelType, channelId, { members: [userId, otherId] }); + channel = client.channel(channelType, channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`${channelType}:${channelId}`); await channel.watch(); }); @@ -49,39 +51,44 @@ describe('_default/04-messages/07-silent_messages.md', () => { }); it('sends a silent message', async () => { - const tripData = { title: 'Trip to the airport', distance_km: 12 }; + const tripData = { distance_km: 12 }; const before = await unreadCountOfOther(); + const sendSpy = vi.spyOn(channel, 'sendMessage'); // #region snippet docs="_default/04-messages/07-silent_messages.md" heading="Silent Messages" tab="JavaScript" index=1 const message = { text: 'You completed your trip', silent: true, - attachments: [{ type: 'trip', ...tripData }], + attachments: [{ type: 'trip', custom: tripData }], }; - await channel.sendMessage(message); + await channel.sendMessage({ message }); // #endregion snippet - const { messages } = await channel.query({ messages: { limit: 2 } }); - const sent = messages[0]; + const { message: sent } = await sendSpy.mock.results[0].value; + sendSpy.mockRestore(); expect(sent.text).toBe('You completed your trip'); expect(sent.silent).toBe(true); - expect(sent.attachments?.[0]?.type).toBe('trip'); + expect(sent.attachments[0]?.type).toBe('trip'); + expect(sent.attachments[0]?.custom.distance_km).toBe(12); expect(await unreadCountOfOther()).toBe(before); // A regular message does count, so the check above is meaningful. - await channel.sendMessage({ text: 'Regular message' }); + await channel.sendMessage({ message: { text: 'Regular message' } }); expect(await unreadCountOfOther()).toBe(before + 1); }); it('sends a system message', async () => { + const sendSpy = vi.spyOn(channel, 'sendMessage'); // #region snippet docs="_default/04-messages/07-silent_messages.md" heading="System Messages" tab="JavaScript" index=1 await channel.sendMessage({ - text: 'You completed your trip', - type: 'system', + message: { + text: 'You completed your trip', + type: 'system', + }, }); // #endregion snippet - const { messages } = await channel.query({ messages: { limit: 1 } }); - const sent = messages[messages.length - 1]; + const { message: sent } = await sendSpy.mock.results[0].value; + sendSpy.mockRestore(); expect(sent.text).toBe('You completed your trip'); expect(sent.type).toBe('system'); }); diff --git a/test/docs-snippets/pages/04-messages/09-message_reminders.test.ts b/test/docs-snippets/pages/04-messages/09-message_reminders.test.ts index 6ce9a3dd3c..df902a1812 100644 --- a/test/docs-snippets/pages/04-messages/09-message_reminders.test.ts +++ b/test/docs-snippets/pages/04-messages/09-message_reminders.test.ts @@ -2,11 +2,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { Channel, QueryRemindersResponse, - ReminderAPIResponse, ReminderFilters, - ReminderSort, + SortParamRequest, StreamChat, } from '../../../../src'; +import { nsToMs } from '../../../../src'; import { disconnectClients, getClientSideClient, @@ -37,7 +37,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { beforeAll(async () => { cleanup.users.push(userId); client = await getClientSideClient({ id: userId }); - channel = client.channel('messaging', channelId, { members: [userId] }); + channel = client.channel('messaging', channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); // Reminders are off for `messaging` in the test app: enable them for this channel only. @@ -58,7 +58,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { 'page4', ]; for (const name of names) { - ids[name] = (await channel.sendMessage({ text: name })).message.id; + ids[name] = (await channel.sendMessage({ message: { text: name } })).message.id; } }); @@ -77,33 +77,29 @@ describe('_default/04-messages/09-message_reminders.md', () => { // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Creating a Message Reminder" tab="JavaScript" index=1 // COPY: messageID="message-id", anotherMessageID="another-message-id" // Create a reminder with a specific due date (direct API call) - await client.createReminder({ - messageId: messageID, + await client.createReminder( + { message_id: messageID }, // Remind in offsetMs - remind_at: new Date(new Date().getTime() + offsetMs).toISOString(), - }); + { remind_at: new Date(new Date().getTime() + offsetMs) }, + ); // Create a reminder with a specific due date (client state optimistic update) - await client.reminders.upsertReminder({ - messageId: messageID, - remind_at: new Date(new Date().getTime() + offsetMs).toISOString(), - }); + await client.reminders.upsertReminder( + { message_id: messageID }, + { remind_at: new Date(new Date().getTime() + offsetMs) }, + ); // Create a "Save for later" reminder without a specific time (direct API call) - await client.createReminder({ - messageId: anotherMessageID, - }); + await client.createReminder({ message_id: anotherMessageID }); // Create a "Save for later" reminder without a specific time (client state optimistic update) - await client.reminders.upsertReminder({ - messageId: messageID, - }); + await client.reminders.upsertReminder({ message_id: messageID }); // #endregion snippet - const first = (await createSpy.mock.results[0].value) as ReminderAPIResponse; + const first = await createSpy.mock.results[0].value; createSpy.mockRestore(); expect(first.reminder.message_id).toBe(ids.create); - expect(new Date(first.reminder.remind_at ?? 0).getTime()).toBeGreaterThanOrEqual( + expect(nsToMs(first.reminder.remind_at ?? 0)).toBeGreaterThanOrEqual( before + offsetMs - 1000, ); expect(client.reminders.getFromState(ids.create)).toBeDefined(); @@ -114,45 +110,39 @@ describe('_default/04-messages/09-message_reminders.md', () => { it('updates reminders', async () => { const messageID = ids.update; const newOffsetMs = 2 * oneHour; - await client.createReminder({ - messageId: ids.update, - remind_at: new Date(Date.now() + oneHour).toISOString(), - }); + await client.createReminder( + { message_id: ids.update }, + { remind_at: new Date(Date.now() + oneHour) }, + ); const updateSpy = vi.spyOn(client, 'updateReminder'); const before = Date.now(); // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Updating a Message Reminder" tab="JavaScript" index=1 // COPY: messageID="message-id" // Update a reminder with a new due date (direct API call) - await client.updateReminder({ - messageId: messageID, + await client.updateReminder( + { message_id: messageID }, // Remind in newOffsetMs - remind_at: new Date(new Date().getTime() + newOffsetMs).toISOString(), - }); + { remind_at: new Date(new Date().getTime() + newOffsetMs) }, + ); // Update a reminder with a new due date (client state optimistic update) - await client.reminders.upsertReminder({ - messageId: messageID, + await client.reminders.upsertReminder( + { message_id: messageID }, // Remind in newOffsetMs - remind_at: new Date(new Date().getTime() + newOffsetMs).toISOString(), - }); + { remind_at: new Date(new Date().getTime() + newOffsetMs) }, + ); - // Convert a timed reminder to "Save for later" (direct API call) - await client.updateReminder({ - messageId: messageID, - remind_at: null, - }); + // Convert a timed reminder to "Save for later" by omitting remind_at (direct API call) + await client.updateReminder({ message_id: messageID }); // Convert a timed reminder to "Save for later" (client state optimistic update) - await client.reminders.upsertReminder({ - messageId: messageID, - remind_at: null, - }); + await client.reminders.upsertReminder({ message_id: messageID }); // #endregion snippet - const first = (await updateSpy.mock.results[0].value) as ReminderAPIResponse; + const first = await updateSpy.mock.results[0].value; updateSpy.mockRestore(); - expect(new Date(first.reminder.remind_at ?? 0).getTime()).toBeGreaterThanOrEqual( + expect(nsToMs(first.reminder.remind_at ?? 0)).toBeGreaterThanOrEqual( before + newOffsetMs - 1000, ); expect((await reminderOf(ids.update))?.remind_at ?? null).toBeNull(); @@ -161,14 +151,14 @@ describe('_default/04-messages/09-message_reminders.md', () => { it('deletes reminders', async () => { const messageID = ids.delete; const anotherMessageID = ids.deleteOther; - await client.createReminder({ messageId: ids.delete }); - await client.reminders.upsertReminder({ messageId: ids.deleteOther }); + await client.createReminder({ message_id: ids.delete }); + await client.reminders.upsertReminder({ message_id: ids.deleteOther }); expect(client.reminders.getFromState(ids.deleteOther)).toBeDefined(); // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Deleting a Message Reminder" tab="JavaScript" index=1 // COPY: messageID="message-id", anotherMessageID="another-message-id" // Delete a reminder for a message with id 'message-id' (direct API call) - await client.deleteReminder(messageID); + await client.deleteReminder({ message_id: messageID }); // Delete a reminder for a message with id 'another-message-id' (client state optimistic update) await client.reminders.deleteReminder(anotherMessageID); @@ -237,17 +227,17 @@ describe('_default/04-messages/09-message_reminders.md', () => { // #endregion snippet try { - await client.createReminder({ messageId: ids.events }); - await client.updateReminder({ - messageId: ids.events, - remind_at: new Date(Date.now() + oneHour).toISOString(), - }); - await client.deleteReminder(ids.events); + await client.createReminder({ message_id: ids.events }); + await client.updateReminder( + { message_id: ids.events }, + { remind_at: new Date(Date.now() + oneHour) }, + ); + await client.deleteReminder({ message_id: ids.events }); // The earliest allowed due time is one minute ahead; the due event arrives shortly after. - await client.createReminder({ - messageId: ids.due, - remind_at: new Date(Date.now() + 61000).toISOString(), - }); + await client.createReminder( + { message_id: ids.due }, + { remind_at: new Date(Date.now() + 61000) }, + ); await retry( () => { expect(logged('Reminder created for message:', ids.events)).toBe(true); @@ -273,10 +263,10 @@ describe('_default/04-messages/09-message_reminders.md', () => { it('filters reminders', async () => { // Runs after the events test, so the `due` reminder is overdue by now. - const timed = await client.createReminder({ - messageId: ids.events, - remind_at: new Date(Date.now() + oneHour).toISOString(), - }); + const timed = await client.createReminder( + { message_id: ids.events }, + { remind_at: new Date(Date.now() + oneHour) }, + ); expect(timed.reminder.remind_at).toBeDefined(); const querySpy = vi.spyOn(client, 'queryReminders'); @@ -285,21 +275,21 @@ describe('_default/04-messages/09-message_reminders.md', () => { // Create filter for overdue reminders await client.queryReminders({ filter: { - remind_at: { $lte: new Date().toISOString() }, - }, - sort: { - remind_at: -1, // sort from the most recently expired + remind_at: { $lte: new Date() }, }, + sort: [ + { field: 'remind_at', direction: -1 }, // sort from the most recently expired + ], }); // Create filter for upcoming reminders await client.queryReminders({ filter: { - remind_at: { $gt: new Date().toISOString() }, - }, - sort: { - remind_at: 1, // sort from the nearest to expire + remind_at: { $gt: new Date() }, }, + sort: [ + { field: 'remind_at', direction: 1 }, // sort from the nearest to expire + ], }); // Create filter for reminders with no due date (saved for later) @@ -307,9 +297,9 @@ describe('_default/04-messages/09-message_reminders.md', () => { filter: { remind_at: null, }, - sort: { - created_at: -1, // sort from the most recently created - }, + sort: [ + { field: 'created_at', direction: -1 }, // sort from the most recently created + ], }); // Client-side calls with local state updates before starting the pagination. @@ -317,27 +307,27 @@ describe('_default/04-messages/09-message_reminders.md', () => { // Create filter for overdue reminders client.reminders.paginator.filters = { - remind_at: { $lte: new Date().toISOString() }, - }; - client.reminders.paginator.sort = { - remind_at: -1, // sort from the most recently expired + remind_at: { $lte: new Date() }, }; + client.reminders.paginator.sort = [ + { field: 'remind_at', direction: -1 }, // sort from the most recently expired + ]; // Create filter for upcoming reminders client.reminders.paginator.filters = { - remind_at: { $gt: new Date().toISOString() }, - }; - client.reminders.paginator.sort = { - remind_at: 1, // sort from the nearest to expire + remind_at: { $gt: new Date() }, }; + client.reminders.paginator.sort = [ + { field: 'remind_at', direction: 1 }, // sort from the nearest to expire + ]; // Create filter for reminders with no due date (saved for later) client.reminders.paginator.filters = { remind_at: null, }; - client.reminders.paginator.sort = { - created_at: -1, // sort from the most recently created - }; + client.reminders.paginator.sort = [ + { field: 'created_at', direction: -1 }, // sort from the most recently created + ]; // adjust the page size client.reminders.paginator.pageSize = 15; @@ -365,14 +355,14 @@ describe('_default/04-messages/09-message_reminders.md', () => { it('paginates reminders', async () => { const pageIds = [ids.page1, ids.page2, ids.page3, ids.page4]; for (const [i, messageId] of pageIds.entries()) { - await client.createReminder({ - messageId, - remind_at: new Date(Date.now() + 3 * oneHour + i * 60000).toISOString(), - }); + await client.createReminder( + { message_id: messageId }, + { remind_at: new Date(Date.now() + 3 * oneHour + i * 60000) }, + ); } const filter: ReminderFilters = { message_id: { $in: pageIds } }; const filters = filter; - const sort: ReminderSort = { remind_at: 1 }; + const sort: SortParamRequest[] = [{ field: 'remind_at', direction: 1 }]; const limit = 2; const firstPage = await client.queryReminders({ filter, sort, limit }); const next = firstPage.next; diff --git a/test/docs-snippets/pages/04-messages/10-message_receipts.test.ts b/test/docs-snippets/pages/04-messages/10-message_receipts.test.ts index 6ea763ddcb..58845562c0 100644 --- a/test/docs-snippets/pages/04-messages/10-message_receipts.test.ts +++ b/test/docs-snippets/pages/04-messages/10-message_receipts.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Channel, Event } from '../../../../src'; +import type { Channel, EventPayload } from '../../../../src'; import { StreamChat } from '../../../../src'; import { createUserToken, @@ -28,7 +28,7 @@ describe('_default/04-messages/10-message_receipts.md', () => { let observer: StreamChat; let control: StreamChat; let observerChannel: Channel; - const events: Event[] = []; + const events: EventPayload<'message.delivered' | 'message.read'>[] = []; const clients: StreamChat[] = []; const newChatClient = () => { @@ -48,7 +48,9 @@ describe('_default/04-messages/10-message_receipts.md', () => { control = await getClientSideClient({ id: controlId }); clients.push(observer, control); observerChannel = observer.channel('messaging', channelId, { - members: [observerId, controlId, deliveryUserId, readUserId], + members: [observerId, controlId, deliveryUserId, readUserId].map((user_id) => ({ + user_id, + })), }); cleanup.channels.push(cid); await observerChannel.watch(); @@ -86,7 +88,9 @@ describe('_default/04-messages/10-message_receipts.md', () => { // The user confirms delivery, then the control user does: only the control's // `message.delivered` reaches the other members. - const { message } = await observerChannel.sendMessage({ text: 'delivered?' }); + const { message } = await observerChannel.sendMessage({ + message: { text: 'delivered?' }, + }); const latest_delivered_messages = [{ cid, id: message.id }]; await chatClient.markChannelsDelivered({ latest_delivered_messages }); await control.markChannelsDelivered({ latest_delivered_messages }); @@ -122,7 +126,7 @@ describe('_default/04-messages/10-message_receipts.md', () => { // The user reads the channel, then the control user does: only the control's // `message.read` reaches the other members. - await observerChannel.sendMessage({ text: 'read?' }); + await observerChannel.sendMessage({ message: { text: 'read?' } }); const readerChannel = chatClient.channel('messaging', channelId); await readerChannel.watch(); const controlChannel = control.channel('messaging', channelId); diff --git a/test/docs-snippets/pages/05-features/02-events.test.ts b/test/docs-snippets/pages/05-features/02-events.test.ts index a19ede5d41..824e7fc0be 100644 --- a/test/docs-snippets/pages/05-features/02-events.test.ts +++ b/test/docs-snippets/pages/05-features/02-events.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, Event, StreamChat } from '../../../../src'; +import type { Channel, Event, EventPayload, StreamChat } from '../../../../src'; import { disconnectClients, getClientSideClient, @@ -22,7 +22,9 @@ describe('_default/05-features/02-events.md', () => { // the events of other tests. const createWatchedChannel = async () => { const id = uniqueId('channel'); - const channel = client.channel('messaging', id, { members: [userId, otherId] }); + const channel = client.channel('messaging', id, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`messaging:${id}`); await channel.watch(); return channel; @@ -30,7 +32,7 @@ describe('_default/05-features/02-events.md', () => { // Drops the listeners a snippet registered on a channel, once its test is done. const removeListeners = (channel: Channel) => { - channel.listeners = {}; + channel.listeners.clear(); }; const sendAsOther = (channel: Channel, text: string) => @@ -117,7 +119,7 @@ describe('_default/05-features/02-events.md', () => { // COPY: channelId="my-conversation-123", johnId="john", jackId="jack" // Watch a channel with presence enabled to receive user presence events const channel = client.channel('messaging', channelId, { - members: [johnId, jackId], + members: [{ user_id: johnId }, { user_id: jackId }], }); const state = await channel.watch({ presence: true }); @@ -131,7 +133,7 @@ describe('_default/05-features/02-events.md', () => { // #endregion snippet presenceListener = onSpy.mock.results[0]?.value as { unsubscribe: () => void }; - expect(state.channel.id).toBe(channelId); + expect(state.channel?.id).toBe(channelId); expect(state.members.map((m) => m.user_id).sort()).toEqual([johnId, jackId].sort()); // Jack comes online, then goes offline. @@ -155,39 +157,41 @@ describe('_default/05-features/02-events.md', () => { it('listens for connection events', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - const onSpy = vi.spyOn(client, 'on'); - let listener: { unsubscribe: () => void } | undefined; + const subscribeSpy = vi.spyOn(client.wsConnection.state, 'subscribeWithSelector'); + let unsubscribe: (() => void) | undefined; try { // #region snippet docs="_default/05-features/02-events.md" heading="Connection Events" tab="JavaScript" index=1 - client.on('connection.changed', (e) => { - if (e.online) { - console.log('the connection is up!'); - } else { - console.log('the connection is down!'); - } - }); + // the WebSocket's status is a state store: the handler runs once with + // the current value, then on every change + client.wsConnection.state.subscribeWithSelector( + ({ isHealthy }) => ({ isHealthy }), + ({ isHealthy }) => { + if (isHealthy) { + console.log('the connection is up!'); + } else { + console.log('the connection is down!'); + } + }, + ); // #endregion snippet - listener = onSpy.mock.results[0]?.value as { unsubscribe: () => void }; + unsubscribe = subscribeSpy.mock.results[0]?.value as () => void; + // the first call reports the current (healthy) state + expect(logSpy).toHaveBeenCalledWith('the connection is up!'); + logSpy.mockClear(); - // Simulate the browser going offline and back online. - client.wsConnection?.onlineStatusChanged(new globalThis.Event('offline')); - await retry(() => { - expect(logSpy).toHaveBeenCalledWith('the connection is down!'); - return Promise.resolve(); - }); - client.wsConnection?.onlineStatusChanged(new globalThis.Event('online')); + // Close the socket and open it again, as an app going to the background would. + await client.closeConnection(); + expect(logSpy).toHaveBeenCalledWith('the connection is down!'); + await client.openConnection(); await retry(() => { expect(logSpy).toHaveBeenCalledWith('the connection is up!'); return Promise.resolve(); }); - await retry(() => { - expect(client.wsConnection?.isHealthy).toBe(true); - return Promise.resolve(); - }); + expect(client.wsConnection.isHealthy).toBe(true); } finally { - listener?.unsubscribe(); - onSpy.mockRestore(); + unsubscribe?.(); + subscribeSpy.mockRestore(); logSpy.mockRestore(); } }); @@ -199,7 +203,7 @@ describe('_default/05-features/02-events.md', () => { // #region snippet docs="_default/05-features/02-events.md" heading="Stop Listening for Events" tab="JavaScript" index=1 // remove the handler from all client events - const myClientEventListener = client.on('connection.changed', myClientEventHandler); + const myClientEventListener = client.on('message.new', myClientEventHandler); myClientEventListener.unsubscribe(); // remove the handler from all events on a channel @@ -211,17 +215,16 @@ describe('_default/05-features/02-events.md', () => { const clientControl = vi.fn(); const channelControl = vi.fn(); const controls = [ - client.on('connection.changed', clientControl), + client.on('message.new', clientControl), channel.on('message.new', channelControl), ]; try { - client.dispatchEvent({ type: 'connection.changed', online: true }); await sendAsOther(channel, 'Nobody listens'); await retry(() => { expect(channelControl).toHaveBeenCalled(); + expect(clientControl).toHaveBeenCalled(); return Promise.resolve(); }); - expect(clientControl).toHaveBeenCalled(); expect(myClientEventHandler).not.toHaveBeenCalled(); expect(myChannelEventHandler).not.toHaveBeenCalled(); } finally { @@ -235,14 +238,16 @@ describe('_default/05-features/02-events.md', () => { otherClient = await getClientSideClient({ id: otherId, name: 'Jack' }); const otherChannel = otherClient.channel(channel.type, channel.id); await otherChannel.watch(); - const received: Event[] = []; + const received: EventPayload<'friendship_request'>[] = []; otherChannel.on('friendship_request', (event) => received.push(event)); // #region snippet docs="_default/05-features/02-events.md" heading="To a channel" tab="JavaScript" index=1 // sends an event for the current user to all connect clients on the channel await channel.sendEvent({ - type: 'friendship_request', - text: 'Hey there, long time no see!', + event: { + type: 'friendship_request', + custom: { text: 'Hey there, long time no see!' }, + }, }); // #endregion snippet @@ -250,9 +255,9 @@ describe('_default/05-features/02-events.md', () => { expect(received).toHaveLength(1); return Promise.resolve(); }); - expect(received[0].text).toBe('Hey there, long time no see!'); - expect(received[0].user?.id).toBe(userId); - expect(received[0].cid).toBe(channel.cid); + expect(received[0].custom.text).toBe('Hey there, long time no see!'); + // `user` and `cid` are on the wire, but not on the custom event type + expect(received[0]).toMatchObject({ user: { id: userId }, cid: channel.cid }); await disconnectClients(otherClient); otherClient = undefined; diff --git a/test/docs-snippets/pages/05-features/03-unread.test.ts b/test/docs-snippets/pages/05-features/03-unread.test.ts index f0f2eeb390..ec4301a905 100644 --- a/test/docs-snippets/pages/05-features/03-unread.test.ts +++ b/test/docs-snippets/pages/05-features/03-unread.test.ts @@ -67,12 +67,13 @@ describe('_default/05-features/03-unread.md', () => { console.log(user?.me?.unread_threads); // number of unread threads // Step 2: Listen to events for real-time updates + // (only some events carry the counts, so check for the field first) client.on((event) => { - if (event.total_unread_count !== undefined) { + if ('total_unread_count' in event && event.total_unread_count !== undefined) { console.log(event.total_unread_count); } - if (event.unread_channels !== undefined) { + if ('unread_channels' in event && event.unread_channels !== undefined) { console.log(event.unread_channels); } }); @@ -102,7 +103,7 @@ describe('_default/05-features/03-unread.md', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { // #region snippet docs="_default/05-features/03-unread.md" heading="Unread Counts - Server side" tab="JavaScript" index=1 - const response = await client.getUnreadCount(); + const response = await client.unreadCounts(); console.log(response.total_unread_count); // total unread count for user console.log(response.channels); // distribution of unread counts across channels console.log(response.channel_type); // distribution of unread counts across channel types @@ -158,7 +159,7 @@ describe('_default/05-features/03-unread.md', () => { // #region snippet docs="_default/05-features/03-unread.md" heading="Mark All As Read" tab="JavaScript" index=1 // client-side - await client.markAllRead(); + await client.markChannelsRead(); // #endregion snippet expect(await serverUnread()).toBe(0); @@ -179,19 +180,21 @@ describe('_default/05-features/03-unread.md', () => { // { // id: '2fe6019c-872f-482a-989e-ecf4f786501b', // role: 'user', - // created_at: '2019-04-24T13:09:19.664378Z', - // updated_at: '2019-04-24T13:09:23.784642Z', - // last_active: '2019-04-24T13:09:23.781641Z', + // created_at: 1556111359664378000, // unix nanoseconds + // updated_at: 1556111363784642000, + // last_active: 1556111363781641000, // online: true // }, - // last_read: '2019-04-24T13:09:21.623Z', + // last_read: 1556111361623000000, + // unread_messages: 0, // } //} // #endregion snippet expect(Object.keys(channel.state.read).sort()).toEqual([otherId, userId].sort()); expect(channel.state.read[userId].user.id).toBe(userId); - expect(channel.state.read[userId].last_read).toBeInstanceOf(Date); + expect(channel.state.read[userId].last_read).toEqual(expect.any(Number)); + expect(channel.state.read[userId].unread_messages).toBe(0); expect(logSpy).toHaveBeenCalledWith(channel.state.read); } finally { logSpy.mockRestore(); diff --git a/test/docs-snippets/pages/05-features/04-typing_indicators.test.ts b/test/docs-snippets/pages/05-features/04-typing_indicators.test.ts index 622dde9ca0..9e5cbafe01 100644 --- a/test/docs-snippets/pages/05-features/04-typing_indicators.test.ts +++ b/test/docs-snippets/pages/05-features/04-typing_indicators.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, Event, StreamChat } from '../../../../src'; +import type { Channel, EventPayload, StreamChat } from '../../../../src'; import { disconnectClients, getClientSideClient, @@ -20,18 +20,20 @@ describe('_default/05-features/04-typing_indicators.md', () => { // A fresh channel per test, watched by both users, with a parent message for thread typing. const createWatchedChannels = async () => { const id = uniqueId('channel'); - const channel = client.channel('messaging', id, { members: [userId, otherId] }); + const channel = client.channel('messaging', id, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`messaging:${id}`); await channel.watch(); const otherChannel = otherClient.channel('messaging', id); await otherChannel.watch(); - const { message } = await channel.sendMessage({ text: 'Thread parent' }); + const { message } = await channel.sendMessage({ message: { text: 'Thread parent' } }); return { channel, otherChannel, parentId: message.id }; }; // Records the typing events a watched channel receives. const recordTypingEvents = (channel: Channel) => { - const events: Event[] = []; + const events: EventPayload<'typing.start' | 'typing.stop'>[] = []; channel.on((event) => { if (event.type === 'typing.start' || event.type === 'typing.stop') { events.push(event); @@ -97,7 +99,7 @@ describe('_default/05-features/04-typing_indicators.md', () => { }); await channel.stopTyping(thread_id); } finally { - otherChannel.listeners = {}; + otherChannel.listeners.clear(); } }); @@ -159,7 +161,7 @@ describe('_default/05-features/04-typing_indicators.md', () => { }); } finally { logSpy.mockRestore(); - channel.listeners = {}; + channel.listeners.clear(); } }); }); diff --git a/test/docs-snippets/pages/05-features/05-presence_format.test.ts b/test/docs-snippets/pages/05-features/05-presence_format.test.ts index b8b9a1d129..ddcb994a60 100644 --- a/test/docs-snippets/pages/05-features/05-presence_format.test.ts +++ b/test/docs-snippets/pages/05-features/05-presence_format.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { StreamChat } from '../../../../src'; -import type { Event } from '../../../../src'; +import type { EventPayload } from '../../../../src'; import { createUserToken, disconnectClients, @@ -30,7 +30,7 @@ describe('_default/05-features/05-presence_format.md', () => { // Records the presence events of `userId` the client receives. const recordPresence = (userId: string) => { - const events: Event[] = []; + const events: EventPayload<'user.presence.changed'>[] = []; const { unsubscribe } = client.on('user.presence.changed', (event) => { if (event.user?.id === userId) events.push(event); }); @@ -56,19 +56,19 @@ describe('_default/05-features/05-presence_format.md', () => { // If you pass presence: true to channel.watch it will watch the list of user presence changes. // Note that you can listen to at most 10 users using this API call const channel = client.channel('messaging', channelId, { - members: [johnId, jackId], - color: 'green', + members: [{ user_id: johnId }, { user_id: jackId }], + custom: { color: 'green' }, }); const state = await channel.watch({ presence: true }); - // queryChannels allows you to listen to the members of the channels that are returned + // queryChannelsAndHydrate allows you to listen to the members of the channels that are returned // so this does the same thing as above and listens to online status changes for john and jack - const channels = await client.queryChannels( - { color: 'green' }, - { last_message_at: -1 }, - { presence: true }, - ); + const channels = await client.queryChannelsAndHydrate({ + filter_conditions: { 'custom.color': 'green' }, + sort: [{ field: 'last_message_at', direction: -1 }], + presence: true, + }); // #endregion snippet expect(state.members.map((m) => m.user_id).sort()).toEqual([johnId, jackId].sort()); @@ -95,15 +95,15 @@ describe('_default/05-features/05-presence_format.md', () => { const id = uniqueId('channel'); cleanup.channels.push(`messaging:${id}`); await client - .channel('messaging', id, { members: [johnId, userId] }) + .channel('messaging', id, { members: [{ user_id: johnId }, { user_id: userId }] }) .watch({ presence: true }); const { events, unsubscribe } = recordPresence(userId); try { - const original = userClient.partialUpdateUser.bind(userClient); + const original = userClient.updateUsersPartial.bind(userClient); const responses: Awaited>[] = []; - userClient.partialUpdateUser = async (update) => { - const response = await original(update); + vi.spyOn(userClient, 'updateUsersPartial').mockImplementation(async (request) => { + const response = await original(request); responses.push(response); if (responses.length === 1) { // after "become invisible": the user is invisible and appears offline @@ -116,21 +116,19 @@ describe('_default/05-features/05-presence_format.md', () => { }); } return response; - }; + }); const client = userClient; // #region snippet docs="_default/05-features/05-presence_format.md" heading="Invisible" tab="JavaScript" index=1 // COPY: userId="unique_user_id" // become invisible - await client.partialUpdateUser({ - id: userId, - set: { invisible: true }, + await client.updateUsersPartial({ + users: [{ id: userId, set: { invisible: true } }], }); // become visible - await client.partialUpdateUser({ - id: userId, - set: { invisible: false }, + await client.updateUsersPartial({ + users: [{ id: userId, set: { invisible: false } }], }); // #endregion snippet @@ -212,7 +210,8 @@ describe('_default/05-features/05-presence_format.md', () => { console.log(member.user?.last_active); // #endregion snippet - expect(logSpy.mock.calls).toEqual([[true], [expect.any(String)]]); + // `last_active` is in unix nanoseconds + expect(logSpy.mock.calls).toEqual([[true], [expect.any(Number)]]); } finally { logSpy.mockRestore(); } diff --git a/test/docs-snippets/pages/05-features/07-polls_api.test.ts b/test/docs-snippets/pages/05-features/07-polls_api.test.ts index 396ea62cf2..df773e0e89 100644 --- a/test/docs-snippets/pages/05-features/07-polls_api.test.ts +++ b/test/docs-snippets/pages/05-features/07-polls_api.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, CreatePollData, PollAnswer, StreamChat } from '../../../../src'; +import type { Channel, CreatePollRequest, StreamChat } from '../../../../src'; import { disconnectClients, getClientSideClient, @@ -23,13 +23,15 @@ describe(DOCS, () => { let channel: Channel; /** Creates a poll as the connected user and sends it in a message. */ - const sendPoll = async (data: Partial = {}) => { + const sendPoll = async (data: Partial = {}) => { const { poll } = await client.createPoll({ name: uniqueId('poll'), options: [{ text: 'A' }, { text: 'B' }, { text: 'C' }], ...data, }); - const { message } = await channel.sendMessage({ text: 'Vote!', poll_id: poll.id }); + const { message } = await channel.sendMessage({ + message: { text: 'Vote!', poll_id: poll.id }, + }); return { poll, pollId: poll.id, @@ -54,7 +56,7 @@ describe(DOCS, () => { cleanup.channelTypes.push(channelType); await waitForChannelTypePropagation(); client = await getClientSideClient({ id: userId }); - channel = client.channel(channelType, channelId, { members: [userId] }); + channel = client.channel(channelType, channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`${channelType}:${channelId}`); await channel.watch(); }); @@ -76,13 +78,16 @@ describe(DOCS, () => { name: 'Where should we host our next event?', options: [{ text: 'Amsterdam' }, { text: 'Boulder' }], }); - await channel.sendMessage({ text: 'Vote now!', poll_id: poll.id }); + await channel.sendMessage({ message: { text: 'Vote now!', poll_id: poll.id } }); // Vote on a poll - await client.castPollVote(messageId, pollId, { option_id: firstOptionId }); + await client.castPollVote( + { message_id: messageId, poll_id: pollId }, + { vote: { option_id: firstOptionId } }, + ); // Retrieve poll results - const { poll: retrievedPoll } = await client.getPoll(pollId); + const { poll: retrievedPoll } = await client.getPoll({ poll_id: pollId }); console.log(retrievedPoll.vote_counts_by_option); // { 'option-id': 5 } // #endregion snippet @@ -125,8 +130,10 @@ describe(DOCS, () => { }); const { message } = await channel.sendMessage({ - text: 'We want to know your opinion!', - poll_id: poll.id, + message: { + text: 'We want to know your opinion!', + poll_id: poll.id, + }, }); // message.poll contains all relevant poll data @@ -146,20 +153,20 @@ describe(DOCS, () => { options: [ { text: 'Amsterdam, The Netherlands', - foo: 'bar', + custom: { foo: 'bar' }, }, { text: 'Boulder, CO', - foo: 'baz', + custom: { foo: 'baz' }, }, ], - foo: 'bar', + custom: { foo: 'bar' }, }); // #endregion snippet - expect(poll.poll).toMatchObject({ foo: 'bar' }); - expect(poll.poll.options[0]).toMatchObject({ foo: 'bar' }); - expect(poll.poll.options[1]).toMatchObject({ foo: 'baz' }); + expect(poll.poll.custom).toMatchObject({ foo: 'bar' }); + expect(poll.poll.options[0].custom).toMatchObject({ foo: 'bar' }); + expect(poll.poll.options[1].custom).toMatchObject({ foo: 'baz' }); }); it('casts votes on options', async () => { @@ -171,17 +178,18 @@ describe(DOCS, () => { // #region snippet docs="_default/05-features/07-polls_api.md" heading="Send vote on option" tab="JavaScript" index=1 // COPY: firstOptionId="some-option-id-328904342" - const { vote } = await chatClient.castPollVote(messageId, pollId, { - option_id: firstOptionId, - }); + const { vote } = await chatClient.castPollVote( + { message_id: messageId, poll_id: pollId }, + { vote: { option_id: firstOptionId } }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); const response = await pollInstance?.castVote(optionId, messageId); // #endregion snippet - expect(vote.option_id).toBe(firstOptionId); - expect(response?.vote.option_id).toBe(optionId); + expect(vote?.option_id).toBe(firstOptionId); + expect(response?.vote?.option_id).toBe(optionId); const serverPoll = await getServerPoll(pollId); expect(serverPoll.vote_counts_by_option).toEqual({ [firstOptionId]: 1, @@ -196,17 +204,18 @@ describe(DOCS, () => { const answerText = 'Somewhere sunny'; // #region snippet docs="_default/05-features/07-polls_api.md" heading="Send an answer (if answers are configured to be allowed)" tab="JavaScript" index=1 - const { vote } = await chatClient.castPollVote(messageId, pollId, { - answer_text: 'some-option-id-328904342', - }); + const { vote } = await chatClient.castPollVote( + { message_id: messageId, poll_id: pollId }, + { vote: { answer_text: 'some-option-id-328904342' } }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); const response = await pollInstance?.addAnswer(answerText, messageId); // #endregion snippet - expect((vote as PollAnswer).answer_text).toBe('some-option-id-328904342'); - expect((response?.vote as PollAnswer | undefined)?.answer_text).toBe(answerText); + expect(vote?.answer_text).toBe('some-option-id-328904342'); + expect(response?.vote?.answer_text).toBe(answerText); // Adding an answer replaces the previous one. const serverPoll = await getServerPoll(pollId); expect(serverPoll.answers_count).toBe(1); @@ -218,23 +227,31 @@ describe(DOCS, () => { it('removes votes', async () => { const { messageId, pollId, optionIds } = await sendPoll(); const chatClient = client; - const { - vote: { id: voteId }, - } = await chatClient.castPollVote(messageId, pollId, { option_id: optionIds[0] }); - const { - vote: { id: anotherVoteId }, - } = await chatClient.castPollVote(messageId, pollId, { option_id: optionIds[1] }); + const castVote = async (option_id: string) => { + const { vote } = await chatClient.castPollVote( + { message_id: messageId, poll_id: pollId }, + { vote: { option_id } }, + ); + if (!vote) throw new Error('castPollVote returned no vote'); + return vote.id; + }; + const voteId = await castVote(optionIds[0]); + const anotherVoteId = await castVote(optionIds[1]); await chatClient.polls.getPoll(pollId); // #region snippet docs="_default/05-features/07-polls_api.md" heading="Removing a vote" tab="JavaScript" index=1 - const { vote } = await chatClient.removePollVote(messageId, pollId, voteId); + const { vote } = await chatClient.deletePollVote({ + message_id: messageId, + poll_id: pollId, + vote_id: voteId, + }); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); const response = await pollInstance?.removeVote(anotherVoteId, messageId); // #endregion snippet - expect(vote.id).toBe(voteId); + expect(vote?.id).toBe(voteId); expect(response).toBeDefined(); const serverPoll = await getServerPoll(pollId); expect(serverPoll.vote_count).toBe(0); @@ -244,22 +261,28 @@ describe(DOCS, () => { const { pollId } = await sendPoll(); const chatClient = client; await chatClient.polls.getPoll(pollId); - const closePoll = vi.spyOn(chatClient, 'closePoll'); + const updatePollPartial = vi.spyOn(chatClient, 'updatePollPartial'); try { // #region snippet docs="_default/05-features/07-polls_api.md" heading="Closing a poll" tab="JavaScript" index=1 - await chatClient.closePoll(pollId); + await chatClient.updatePollPartial( + { poll_id: pollId }, + { set: { is_closed: true } }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); await pollInstance?.close(); // #endregion snippet - expect(closePoll).toHaveBeenCalled(); + expect(updatePollPartial).toHaveBeenCalledWith( + { poll_id: pollId }, + { set: { is_closed: true } }, + ); expect(pollInstance?.data.is_closed).toBe(true); expect((await getServerPoll(pollId)).is_closed).toBe(true); } finally { - closePoll.mockRestore(); + updatePollPartial.mockRestore(); } }); @@ -268,7 +291,7 @@ describe(DOCS, () => { const chatClient = client; // #region snippet docs="_default/05-features/07-polls_api.md" heading="Retrieving a poll" tab="JavaScript" index=1 - const poll = await chatClient.getPoll(pollId); + const poll = await chatClient.getPoll({ poll_id: pollId }); // or if you're using PollManager await chatClient.polls.getPoll(pollId); @@ -295,12 +318,12 @@ describe(DOCS, () => { { id: option1Id, text: 'Amsterdam, The Netherlands', - foo: 'bar', + custom: { foo: 'bar' }, }, { id: option2Id, text: 'Boulder, CO', - foo: 'baz', + custom: { foo: 'baz' }, }, ], }); @@ -308,24 +331,27 @@ describe(DOCS, () => { expect(updatedPoll.poll.name).toBe('Where should we not go to?'); expect(updatedPoll.poll.options).toMatchObject([ - { id: option1Id, text: 'Amsterdam, The Netherlands', foo: 'bar' }, - { id: option2Id, text: 'Boulder, CO', foo: 'baz' }, + { id: option1Id, text: 'Amsterdam, The Netherlands', custom: { foo: 'bar' } }, + { id: option2Id, text: 'Boulder, CO', custom: { foo: 'baz' } }, ]); }); it('partially updates a poll', async () => { - const { pollId } = await sendPoll({ custom_property: 'value' }); + const { pollId } = await sendPoll({ custom: { custom_property: 'value' } }); const chatClient = client; // #region snippet docs="_default/05-features/07-polls_api.md" heading="Partial update" tab="JavaScript" index=1 - const updatedPoll = await chatClient.partialUpdatePoll(pollId, { - set: { name: 'Where should we not go to?' }, - unset: ['custom_property'], - }); + const updatedPoll = await chatClient.updatePollPartial( + { poll_id: pollId }, + { + set: { name: 'Where should we not go to?' }, + unset: ['custom_property'], + }, + ); // #endregion snippet expect(updatedPoll.poll.name).toBe('Where should we not go to?'); - expect(updatedPoll.poll).not.toHaveProperty('custom_property'); + expect(updatedPoll.poll.custom).not.toHaveProperty('custom_property'); }); it('deletes polls', async () => { @@ -335,7 +361,7 @@ describe(DOCS, () => { await chatClient.polls.getPoll(anotherPollId); // #region snippet docs="_default/05-features/07-polls_api.md" heading="Deleting a poll" tab="JavaScript" index=1 - await chatClient.deletePoll(pollId); + await chatClient.deletePoll({ poll_id: pollId }); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(anotherPollId); @@ -353,9 +379,10 @@ describe(DOCS, () => { await chatClient.polls.getPoll(pollId); // #region snippet docs="_default/05-features/07-polls_api.md" heading="**Add poll option**" tab="JavaScript" index=1 - const pollOption = await chatClient.createPollOption(pollId, { - text: 'Another option', - }); + const pollOption = await chatClient.createPollOption( + { poll_id: pollId }, + { text: 'Another option' }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); @@ -379,11 +406,14 @@ describe(DOCS, () => { await chatClient.polls.getPoll(pollId); // #region snippet docs="_default/05-features/07-polls_api.md" heading="Update poll option" tab="JavaScript" index=1 - const updatedPollOption = await chatClient.updatePollOption(pollId, { - id: optionId, - text: 'Updated option', - my_custom_property: 'my_custom_value', - }); + const updatedPollOption = await chatClient.updatePollOption( + { poll_id: pollId }, + { + id: optionId, + text: 'Updated option', + custom: { my_custom_property: 'my_custom_value' }, + }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); @@ -396,7 +426,7 @@ describe(DOCS, () => { expect(updatedPollOption.poll_option).toMatchObject({ id: optionId, text: 'Updated option', - my_custom_property: 'my_custom_value', + custom: { my_custom_property: 'my_custom_value' }, }); expect(pollOption?.poll_option).toMatchObject({ id: optionId, @@ -413,7 +443,7 @@ describe(DOCS, () => { await chatClient.polls.getPoll(pollId); // #region snippet docs="_default/05-features/07-polls_api.md" heading="Delete poll option" tab="JavaScript" index=1 - await chatClient.deletePollOption(pollId, optionId); + await chatClient.deletePollOption({ poll_id: pollId, option_id: optionId }); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); @@ -432,15 +462,18 @@ describe(DOCS, () => { const [option1Id, option2Id, option3Id] = optionIds; const chatClient = client; for (const option_id of [option1Id, option2Id, option3Id]) { - await chatClient.castPollVote(messageId, poll.id, { option_id }); + await chatClient.castPollVote( + { message_id: messageId, poll_id: poll.id }, + { vote: { option_id } }, + ); } // #region snippet docs="_default/05-features/07-polls_api.md" heading="Querying votes" tab="JavaScript" index=1 // retrieve all votes on either option1Id or option2Id - const filter = { - option_id: { $in: [option1Id, option2Id] }, - }; - const { votes } = await chatClient.queryPollVotes(poll.id, filter); + const { votes } = await chatClient.queryPollVotes( + { poll_id: poll.id }, + { filter: { option_id: { $in: [option1Id, option2Id] } } }, + ); // #endregion snippet expect(votes.map((vote) => vote.option_id).sort()).toEqual( @@ -451,19 +484,23 @@ describe(DOCS, () => { it('queries polls', async () => { const { pollId: closedPollId } = await sendPoll(); const { pollId: openPollId } = await sendPoll(); - await client.closePoll(closedPollId); + await client.updatePollPartial( + { poll_id: closedPollId }, + { set: { is_closed: true } }, + ); const chatClient = client; // #region snippet docs="_default/05-features/07-polls_api.md" heading="Querying polls" tab="JavaScript" index=1 // retrieve all polls that are closed for voting sorted by created_at - const { polls } = await chatClient.queryPolls({ is_closed: true }, [ - { created_at: -1 }, - ]); + const { polls } = await chatClient.queryPolls({ + filter: { is_closed: true }, + sort: [{ field: 'created_at', direction: -1 }], + }); // or if you're using PollManager const { polls: pollInstances } = await chatClient.polls.queryPolls( { is_closed: true }, - [{ created_at: -1 }], + [{ field: 'created_at', direction: -1 }], ); // the returned polls will be ones with reactive state // #endregion snippet diff --git a/test/docs-snippets/pages/05-features/08-location_sharing.test.ts b/test/docs-snippets/pages/05-features/08-location_sharing.test.ts index a993824e35..14afcfcfe0 100644 --- a/test/docs-snippets/pages/05-features/08-location_sharing.test.ts +++ b/test/docs-snippets/pages/05-features/08-location_sharing.test.ts @@ -27,7 +27,11 @@ describe('_default/05-features/08-location_sharing.md', () => { await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); cleanup.channels.push(`${channelType}:${channelId}`); - await client.channel(channelType, channelId, { members: [userId, otherId] }).create(); + await client + .channel(channelType, channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }) + .create(); // config_overrides can only be set server-side await serverClient.chat .channel(channelType, channelId) @@ -55,7 +59,7 @@ describe('_default/05-features/08-location_sharing.md', () => { }); // #endregion snippet - const { message } = await client.getMessage(messageId); + const { message } = await client.getMessage({ id: messageId }); expect(message.shared_location).toMatchObject({ latitude: 10, longitude: 10 }); expect(message.shared_location?.end_at).toBeUndefined(); }); @@ -72,7 +76,7 @@ describe('_default/05-features/08-location_sharing.md', () => { // Live location differs from the static location by the termination timestamp end_at. await channel.sendSharedLocation({ created_by_device_id: 'device-id', - end_at: '2225-07-22T09:30:12.507Z', + end_at: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now latitude: 10, longitude: 10, message_id: messageId, @@ -80,20 +84,23 @@ describe('_default/05-features/08-location_sharing.md', () => { // #endregion snippet } - const { message: started } = await client.getMessage(messageId); - expect(started.shared_location?.end_at).toMatch(/^2225-07-22T09:30:12/); + const { message: started } = await client.getMessage({ id: messageId }); + // `end_at` comes back as unix nanoseconds, about an hour from now. + const endAtMs = (started.shared_location?.end_at ?? 0) / 1e6; + expect(endAtMs).toBeGreaterThan(Date.now() + 50 * 60 * 1000); + expect(endAtMs).toBeLessThan(Date.now() + 70 * 60 * 1000); // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Updating live location" tab="JavaScript" index=1 // COPY: messageId="message-id" // simple call to update a location - await client.updateLocation({ + await client.updateLiveLocation({ latitude: 1, longitude: 2, message_id: messageId, }); // #endregion snippet - const { active_live_locations } = await client.getSharedLocations(); + const { active_live_locations } = await client.getUserLiveLocations(); expect( active_live_locations.find((location) => location.message_id === messageId), ).toMatchObject({ latitude: 1, longitude: 2 }); @@ -111,7 +118,7 @@ describe('_default/05-features/08-location_sharing.md', () => { } await retry(async () => { - const { active_live_locations: active } = await client.getSharedLocations(); + const { active_live_locations: active } = await client.getUserLiveLocations(); expect(active.map((location) => location.message_id)).not.toContain(messageId); }); }); @@ -151,6 +158,9 @@ describe('_default/05-features/08-location_sharing.md', () => { // to stop watching and reporting the manager subscriptions have cleaned up manager.unregisterSubscriptions(); + + // release the manager when it is no longer needed + manager.dispose(); // #endregion snippet expect(manager.stateIsReady).toBe(true); @@ -194,12 +204,16 @@ describe('_default/05-features/08-location_sharing.md', () => { const messageId = uniqueId('message'); await channel.sendSharedLocation({ created_by_device_id: 'device-id', - end_at: '2225-07-22T09:30:12.507Z', + end_at: new Date(Date.now() + 60 * 60 * 1000), latitude: 10, longitude: 10, message_id: messageId, }); - await client.updateLocation({ latitude: 1, longitude: 2, message_id: messageId }); + await client.updateLiveLocation({ + latitude: 1, + longitude: 2, + message_id: messageId, + }); await retry(() => { expect(seen).toContainEqual(['message.new', true]); @@ -207,8 +221,11 @@ describe('_default/05-features/08-location_sharing.md', () => { return Promise.resolve(); }); } finally { + // Remove the listeners registered here (the snippet doesn't keep the unsubscribes). + onSpy.mock.results.forEach((r) => { + if (r.type === 'return') r.value.unsubscribe(); + }); onSpy.mockRestore(); - channel.listeners = {}; } }); }); diff --git a/test/docs-snippets/pages/05-features/09-translation.test.ts b/test/docs-snippets/pages/05-features/09-translation.test.ts index cfba8ceb6d..4bb54d5720 100644 --- a/test/docs-snippets/pages/05-features/09-translation.test.ts +++ b/test/docs-snippets/pages/05-features/09-translation.test.ts @@ -54,7 +54,7 @@ describe(DOCS, () => { }); const { message } = await client .channel(pollChannelType, pollChannelId) - .sendMessage({ text: 'Vote now!', poll_id: createdPoll.id }); + .sendMessage({ message: { text: 'Vote now!', poll_id: createdPoll.id } }); return message; }; @@ -72,7 +72,7 @@ describe(DOCS, () => { await waitForChannelTypePropagation(); client = await getClientSideClient({ id: userId, language: 'en' }); spanishClient = await getClientSideClient({ id: spanishUserId, language: 'es' }); - channel = client.channel('messaging', channelId, { members: [userId] }); + channel = client.channel('messaging', channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); }); @@ -88,12 +88,17 @@ describe(DOCS, () => { try { // #region snippet docs="_default/05-features/09-translation.md" heading="Message Translation Endpoint" tab="JavaScript" index=1 await channel.sendMessage({ - id: messageID, - text: 'Hello, I would like to have more information about your product.', + message: { + id: messageID, + text: 'Hello, I would like to have more information about your product.', + }, }); // returns the message.text translated into French - const response = await client.translateMessage(messageID, 'fr'); + const response = await client.translateMessage( + { id: messageID }, + { language: 'fr' }, + ); // the translation will be added to the i18n object console.log(response.message.i18n?.fr_text); @@ -128,9 +133,9 @@ describe(DOCS, () => { const languageUser = await getServerUser(serverClient, languageUserId); expect(languageUser.language).toBe('en'); - expect(languageClient.activeChannels[`messaging:${meltingPotId}`]?.initialized).toBe( - true, - ); + expect( + languageClient.channelManager.get(`messaging:${meltingPotId}`)?.initialized, + ).toBe(true); const meltingPot = await getServerChannel(serverClient, `messaging:${meltingPotId}`); expect(meltingPot.channel?.created_by?.id).toBe(languageUserId); }); @@ -138,7 +143,8 @@ describe(DOCS, () => { it('reads poll translations', async () => { const sent = await sendPollMessage(); // the Spanish member reads the message - const message: MessageResponse = (await spanishClient.getMessage(sent.id)).message; + const message: MessageResponse = (await spanishClient.getMessage({ id: sent.id })) + .message; const client = spanishClient; // #region snippet docs="_default/05-features/09-translation.md" heading="Poll translation" tab="JavaScript" index=1 diff --git a/test/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts b/test/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts index 78eae05a7e..1ce64dcd00 100644 --- a/test/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts +++ b/test/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Message, StreamChat } from '../../../../../src'; +import type { MessageRequest, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -31,7 +31,9 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = it('enables and disables slow mode', async () => { const channelId = uniqueId('channel'); - const channel = client.channel('messaging', channelId, { members: [userId] }); + const channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.create(); // Only admins and moderators can change the cooldown (a channel creator gets 403 code 17). @@ -52,7 +54,7 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = // #endregion snippet const results = await Promise.all(enableSpy.mock.results.map((r) => r.value)); - expect(results.map((r) => r.channel.cooldown)).toEqual([1, 30]); + expect(results.map((r) => r.channel?.cooldown)).toEqual([1, 30]); expect(channel.data?.cooldown ?? 0).toBe(0); const serverChannel = await getServerChannel(serverClient, channel.cid); expect(serverChannel.channel?.cooldown ?? 0).toBe(0); @@ -73,13 +75,13 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = // A regular member, so the cooldown applies. const channel = memberClient.channel('messaging', channelId); await channel.watch(); - const msg: Message = { text: 'Hello' }; + const message: MessageRequest = { text: 'Hello' }; const calls: Array<[string, number]> = []; const disableSendMessageUI = () => calls.push(['disable', Date.now()]); const enableSendMessageUI = () => calls.push(['enable', Date.now()]); // #region snippet docs="_default/05-features/10-advanced/11-slow_mode_and_throttling.md" heading="Channel Slow Mode" tab="JavaScript" index=2 - const p = channel.sendMessage(msg); + const p = channel.sendMessage({ message }); const cooldown = channel.data?.cooldown; if (cooldown != null && cooldown > 0) { @@ -95,7 +97,9 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = // #endregion snippet // The API rejects a second message during the cooldown. - await expect(channel.sendMessage({ text: 'Too soon' })).rejects.toThrow(); + await expect( + channel.sendMessage({ message: { text: 'Too soon' } }), + ).rejects.toThrow(); await retry(() => { expect(calls.map(([name]) => name)).toEqual(['disable', 'enable']); diff --git a/test/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts b/test/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts index 98a87e0140..f1be10d7a4 100644 --- a/test/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts +++ b/test/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts @@ -17,14 +17,18 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const createChannel = async () => { const channelId = uniqueId('channel'); - const channel = client.channel('messaging', channelId, { members: [userId] }); + const channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.create(); return channel; }; const sendParent = async (channel: Channel) => { - const { message } = await channel.sendMessage({ text: 'parent message' }); + const { message } = await channel.sendMessage({ + message: { text: 'parent message' }, + }); return message.id; }; @@ -44,18 +48,17 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Creating a draft message" tab="JavaScript" index=1 const draft = await channel.createDraft({ - text: 'this is a draft message', + message: { text: 'this is a draft message' }, }); // Update the draft const updatedDraft = await channel.createDraft({ - text: 'this is an updated draft message', + message: { text: 'this is an updated draft message' }, }); // Create a draft on a thread const threadDraft = await channel.createDraft({ - text: 'this is a draft message', - parent_id: parentMessageId, + message: { text: 'this is a draft message', parent_id: parentMessageId }, }); // #endregion snippet @@ -74,8 +77,10 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { it('deletes a draft message', async () => { const channel = await createChannel(); const parentMessageId = await sendParent(channel); - await channel.createDraft({ text: 'channel draft' }); - await channel.createDraft({ text: 'thread draft', parent_id: parentMessageId }); + await channel.createDraft({ message: { text: 'channel draft' } }); + await channel.createDraft({ + message: { text: 'thread draft', parent_id: parentMessageId }, + }); // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Deleting a draft message" tab="JavaScript" index=1 // Channel draft @@ -95,8 +100,10 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { it('loads a draft message', async () => { const channel = await createChannel(); const parentMessageId = await sendParent(channel); - await channel.createDraft({ text: 'channel draft' }); - await channel.createDraft({ text: 'thread draft', parent_id: parentMessageId }); + await channel.createDraft({ message: { text: 'channel draft' } }); + await channel.createDraft({ + message: { text: 'thread draft', parent_id: parentMessageId }, + }); // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Loading a draft message" tab="JavaScript" index=1 // Channel draft @@ -117,9 +124,9 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const channel2 = await createChannel(); const otherChannel = await createChannel(); // Sequential, so `created_at: -1` puts channel2's draft before channel1's. - await channel1.createDraft({ text: 'draft 1' }); - await channel2.createDraft({ text: 'draft 2' }); - await otherChannel.createDraft({ text: 'other draft' }); + await channel1.createDraft({ message: { text: 'draft 1' } }); + await channel2.createDraft({ message: { text: 'draft 2' } }); + await otherChannel.createDraft({ message: { text: 'other draft' } }); const channel1Cid = channel1.cid; const channel2Cid = channel2.cid; const querySpy = vi.spyOn(client, 'queryDrafts'); @@ -134,11 +141,13 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { filter: { channel_cid: { $in: [channel1Cid, channel2Cid] }, }, - sort: [{ created_at: -1 }], + sort: [{ field: 'created_at', direction: -1 }], }); // #endregion snippet - expect(querySpy.mock.calls[1][0]?.sort).toEqual([{ created_at: -1 }]); + expect(querySpy.mock.calls[1][0]?.sort).toEqual([ + { field: 'created_at', direction: -1 }, + ]); querySpy.mockRestore(); expect(response.drafts.map((d) => d.channel_cid)).toEqual( expect.arrayContaining([channel1Cid, channel2Cid, otherChannel.cid]), @@ -151,7 +160,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { // The opposite direction proves the sort is applied. const ascending = await client.queryDrafts({ filter: { channel_cid: { $in: [channel1Cid, channel2Cid] } }, - sort: [{ created_at: 1 }], + sort: [{ field: 'created_at', direction: 1 }], }); expect(ascending.drafts.map((d) => d.channel_cid)).toEqual([ channel1Cid, @@ -163,7 +172,9 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const channel = await createChannel(); for (let i = 0; i < 7; i++) { const parentId = await sendParent(channel); - await channel.createDraft({ text: `thread draft ${i}`, parent_id: parentId }); + await channel.createDraft({ + message: { text: `thread draft ${i}`, parent_id: parentId }, + }); } // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Pagination" tab="JavaScript" index=1 @@ -201,7 +212,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { }); // #endregion snippet - await channel.createDraft({ text: 'draft for the event' }); + await channel.createDraft({ message: { text: 'draft for the event' } }); await retry(() => { expect(logSpy).toHaveBeenCalledWith('channel_cid', channel.cid); return Promise.resolve(); diff --git a/test/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts b/test/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts index 80a175fa57..fddd114218 100644 --- a/test/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts +++ b/test/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts @@ -44,7 +44,9 @@ describe('_default/05-features/10-advanced/13-private_messaging.md', () => { client = await getClientSideClient({ id: userId }); cleanup.channels.push(`messaging:${channelId}`); await client - .channel('messaging', channelId, { members: [userId, janeId, bobId] }) + .channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: janeId }, { user_id: bobId }], + }) .create(); }); @@ -61,9 +63,11 @@ describe('_default/05-features/10-advanced/13-private_messaging.md', () => { // Send a message only visible to Jane const message = await channel.sendMessage({ - text: 'Hi Jane, your driver John will be at your location in 1 minute', - type: 'system', - restricted_visibility: [janeId], + message: { + text: 'Hi Jane, your driver John will be at your location in 1 minute', + type: 'system', + restricted_visibility: [janeId], + }, }); // #endregion snippet diff --git a/test/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts b/test/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts index c60f642b04..c3fae40920 100644 --- a/test/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts +++ b/test/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts @@ -23,7 +23,11 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); cleanup.channels.push(`messaging:${channelId}`); - await client.channel('messaging', channelId, { members: [userId, otherId] }).create(); + await client + .channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }) + .create(); // Pending messages can only be sent server-side. for (const text of ['pending 1', 'pending 2']) { @@ -50,7 +54,7 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Querying pending messages" tab="JavaScript" index=1 // COPY: pendingMessageId="pending_message_id", pendingMessageId1="pending_message_id_1", pendingMessageId2="pending_message_id_2" // To retrieve a single message - const response = await client.getMessage(pendingMessageId); + const response = await client.getMessage({ id: pendingMessageId }); // To retrieve several messages from one channel const messages = await channel.getMessagesById([ @@ -65,7 +69,7 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { // Pending messages are only visible to their sender. const otherClient = await getClientSideClient({ id: otherId }); try { - await expect(otherClient.getMessage(pendingMessageId)).rejects.toThrow( + await expect(otherClient.getMessage({ id: pendingMessageId })).rejects.toThrow( /doesn't exist/, ); } finally { @@ -77,16 +81,19 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Query channels" tab="JavaScript" index=1 // COPY: userId="thierry" // Querying multiple channels returns each channel's pending messages - const channels = await client.queryChannels( - { type: 'messaging', members: { $in: [userId] } }, - [{ last_message_at: -1 }], - ); + const channels = await client.queryChannelsAndHydrate({ + filter_conditions: { type: 'messaging', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + }); + + // Pending messages of the first channel + const pendingMessages = channels[0]?.state.pending_messages; // #endregion snippet - const channel = channels.find((c) => c.id === channelId); - expect(channel).toBeDefined(); - expect( - (channel?.state.pending_messages ?? []).map((p) => p.message.id).sort(), - ).toEqual([...pendingIds].sort()); + // The user's only channel is the one with the pending messages. + expect(channels.map((c) => c.id)).toEqual([channelId]); + expect((pendingMessages ?? []).map((p) => p.message?.id).sort()).toEqual( + [...pendingIds].sort(), + ); }); }); diff --git a/test/docs-snippets/pages/12-best_practices/02-moderation.test.ts b/test/docs-snippets/pages/12-best_practices/02-moderation.test.ts index 4a4a272fab..2df00fbbed 100644 --- a/test/docs-snippets/pages/12-best_practices/02-moderation.test.ts +++ b/test/docs-snippets/pages/12-best_practices/02-moderation.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { FlagMessageResponse, StreamChat } from '../../../../src'; +import type { StreamChat } from '../../../../src'; import { disconnectClients, getClientSideClient, @@ -12,6 +12,7 @@ import { getServerMember, sendServerMessage, } from '../../helpers/server'; +import { retry } from '../../helpers/wait'; const DOCS = '_default/12-best_practices/02-moderation.md'; @@ -38,6 +39,14 @@ describe(DOCS, () => { return message.id; }; + /** The flags on `messageId`'s review queue item (server-side). */ + const flagsOn = async (messageId: string) => { + const { items } = await serverClient.moderation.queryReviewQueue({ + filter: { entity_type: 'stream:chat:v1:message', entity_id: messageId }, + }); + return items.flatMap((item) => item.flags); + }; + beforeAll(async () => { cleanup.users.push(ownerId, adminId, jamesBondId, spammerId, ...blockTargets); await serverClient.upsertUsers( @@ -47,7 +56,7 @@ describe(DOCS, () => { adminClient = await getClientSideClient({ id: adminId, role: 'admin' }); const channel = client.channel('messaging', channelId, { - members: [ownerId, spammerId], + members: [{ user_id: ownerId }, { user_id: spammerId }], }); cleanup.channels.push(`messaging:${channelId}`); await channel.create(); @@ -60,7 +69,7 @@ describe(DOCS, () => { it('adds a member with the moderator role', async () => { const id = uniqueId('channel'); - const channel = client.channel('messaging', id, { members: [ownerId] }); + const channel = client.channel('messaging', id, { members: [{ user_id: ownerId }] }); cleanup.channels.push(`messaging:${id}`); await channel.create(); @@ -78,7 +87,7 @@ describe(DOCS, () => { it('enables and disables slow mode', async () => { const id = uniqueId('channel'); - const channel = client.channel('messaging', id, { members: [ownerId] }); + const channel = client.channel('messaging', id, { members: [{ user_id: ownerId }] }); cleanup.channels.push(`messaging:${id}`); await channel.create(); // Changing the cooldown needs UpdateChannelCooldown (a channel creator gets 403 code 17). @@ -99,7 +108,7 @@ describe(DOCS, () => { // #endregion snippet const results = await Promise.all(enableSpy.mock.results.map((r) => r.value)); - expect(results.map((r) => r.channel.cooldown)).toEqual([1, 30]); + expect(results.map((r) => r.channel?.cooldown)).toEqual([1, 30]); const serverChannel = await getServerChannel(serverClient, channel.cid); expect(serverChannel.channel?.cooldown ?? 0).toBe(0); }); @@ -109,40 +118,40 @@ describe(DOCS, () => { flaggedMessageIds.push(messageId); // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Flag" tab="JavaScript" index=1 + // A user's flag on a message is recorded once: flagging it again doesn't update the reason or custom data // Flag a message - const flag = await client.flagMessage(messageId); + const flag = await client.moderation.flagMessage(messageId); // Flag with a reason and custom data - const flagWithReason = await client.flagMessage(messageId, { - reason: 'spammy_user', + const flagWithReason = await client.moderation.flagMessage(messageId, 'spammy_user', { custom: { user_comment: 'This user is spamming.', }, }); // #endregion snippet - expect(flag.flag.target_message_id).toBe(messageId); - expect(flag.flag.user.id).toBe(ownerId); - expect(flagWithReason.flag.reason).toBe('spammy_user'); - expect(flagWithReason.flag.custom).toEqual({ - user_comment: 'This user is spamming.', + // Both flags land on the message's review queue item. + expect(flag.item_id).toEqual(expect.any(String)); + expect(flagWithReason.item_id).toBe(flag.item_id); + await retry(async () => { + const flags = await flagsOn(messageId); + // Only the first flag is recorded, without a reason. + expect(flags.map((f) => [f.user_id, f.reason])).toEqual([[ownerId, undefined]]); }); }); it('flags a message with reasons and custom data', async () => { const messageID = await sendSpamMessage('Buy more!'); flaggedMessageIds.push(messageID); - const flagSpy = vi.spyOn(client, 'flagMessage'); + const flagSpy = vi.spyOn(client.moderation, 'flagMessage'); // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Reasons & custom data" tab="JavaScript" index=1 + // A user's flag on a message is recorded once: flagging it again doesn't update the reason or custom data // flag with a reason - let flag = await client.flagMessage(messageID, { - reason: 'spammy_user', - }); + let flag = await client.moderation.flagMessage(messageID, 'spammy_user'); // flag with a reason and additional custom data - flag = await client.flagMessage(messageID, { - reason: 'spammy_user', + flag = await client.moderation.flagMessage(messageID, 'spammy_user', { custom: { user_comment: 'This user is spamming the homepage.', page: 'homepage', @@ -150,25 +159,28 @@ describe(DOCS, () => { }); // flag with only custom data - flag = await client.flagMessage(messageID, { + flag = await client.moderation.flagMessage(messageID, undefined, { custom: { page: 'homepage', }, }); // #endregion snippet - const [withReason, withReasonAndCustom]: FlagMessageResponse[] = await Promise.all( - flagSpy.mock.results.map((r) => r.value), - ); + const responses = await Promise.all(flagSpy.mock.results.map((r) => r.value)); flagSpy.mockRestore(); - expect(withReason.flag.reason).toBe('spammy_user'); - expect(withReasonAndCustom.flag.reason).toBe('spammy_user'); - expect(withReasonAndCustom.flag.custom).toEqual({ - user_comment: 'This user is spamming the homepage.', - page: 'homepage', + // All three flags land on the message's review queue item. + expect(responses.map((r) => r.item_id)).toEqual([ + flag.item_id, + flag.item_id, + flag.item_id, + ]); + await retry(async () => { + const flags = await flagsOn(messageID); + // Only the first flag is recorded: its reason, no custom data. + expect(flags.map((f) => [f.user_id, f.reason, f.custom])).toEqual([ + [ownerId, 'spammy_user', undefined], + ]); }); - expect(flag.flag.target_message_id).toBe(messageID); - expect(flag.flag.custom).toEqual({ page: 'homepage' }); }); it('queries flagged messages from the review queue', async () => { @@ -177,10 +189,10 @@ describe(DOCS, () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Query Flagged Messages" tab="JavaScript" index=1 - const response = await client.moderation.queryReviewQueue( - { entity_type: 'stream:chat:v1:message' }, - [{ created_at: -1 }], - ); + const response = await client.moderation.queryReviewQueue({ + filter: { entity_type: 'stream:chat:v1:message' }, + sort: [{ field: 'created_at', direction: -1 }], + }); for (const item of response.items) { console.log(item.message?.id); @@ -230,7 +242,7 @@ describe(DOCS, () => { expect(before.blocks.map((b) => b.blocked_user_id)).toContain(blockedUser); // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Unblock user" tab="JavaScript" index=1 - await client.unBlockUser(blockedUser); + await client.unblockUser(blockedUser); // #endregion snippet // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="List of Blocked Users" tab="JavaScript" index=1 @@ -263,7 +275,7 @@ describe(DOCS, () => { // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="JavaScript" index=2 // Unblocks a user on behalf of the connected user. try { - await chatClient.unBlockUser(userId); + await chatClient.unblockUser(userId); } catch (err) { console.log('Error unblocking user:', err); } diff --git a/test/docs-snippets/pages/javascript/01-quick_start/01-plain_js_introduction.test.ts b/test/docs-snippets/pages/javascript/01-quick_start/01-plain_js_introduction.test.ts index 2cb8608ea8..0321130e79 100644 --- a/test/docs-snippets/pages/javascript/01-quick_start/01-plain_js_introduction.test.ts +++ b/test/docs-snippets/pages/javascript/01-quick_start/01-plain_js_introduction.test.ts @@ -44,7 +44,7 @@ describe(PAGE, () => { // #endregion snippet expect(client.userID).toBe(userId); - expect(client.wsConnection?.isHealthy).toBe(true); + expect(client.wsConnection.isHealthy).toBe(true); const user = await getServerUser(serverClient, userId); expect(user.name).toBe('Jim Lahey'); expect(user.image).toBe('https://i.imgur.com/fR9Jz14.png'); @@ -55,7 +55,7 @@ describe(PAGE, () => { // COPY: apiKey="{{ api_key }}", channelId="travel" const client = StreamChat.getInstance(apiKey); const channel = client.channel('messaging', channelId, { - name: 'Awesome channel about traveling', + custom: { name: 'Awesome channel about traveling' }, }); // fetch the channel state, subscribe to future updates @@ -63,21 +63,23 @@ describe(PAGE, () => { // #endregion snippet expect(client.userID).toBe(userId); - expect(state.channel.id).toBe(channelId); - expect(state.channel.name).toBe('Awesome channel about traveling'); + expect(state.channel?.id).toBe(channelId); + expect(state.channel?.custom.name).toBe('Awesome channel about traveling'); expect(channel.initialized).toBe(true); // #region snippet docs="javascript/01-quick_start/01-plain_js_introduction.md" heading="Messages" tab="JavaScript" index=1 const text = 'I’m mowing the air Rand, I’m mowing the air.'; const response = await channel.sendMessage({ - text, - customField: '123', + message: { + text, + custom: { customField: '123' }, + }, }); // #endregion snippet expect(response.message.text).toBe(text); - expect(response.message.customField).toBe('123'); + expect(response.message.custom.customField).toBe('123'); const { message } = await serverClient.chat.getMessage({ id: response.message.id }); expect(message.custom.customField).toBe('123'); @@ -86,7 +88,9 @@ describe(PAGE, () => { // #region snippet docs="javascript/01-quick_start/01-plain_js_introduction.md" heading="Events" tab="JavaScript" index=1 channel.on('message.new', (event) => { console.log('received a new message', event.message?.text); - console.log(`Now have ${channel.state.messages.length} stored in local state`); + console.log( + `Now have ${channel.messagePaginator.items?.length} stored in local state`, + ); }); // #endregion snippet @@ -102,7 +106,7 @@ describe(PAGE, () => { }); } finally { logSpy.mockRestore(); - channel.listeners = {}; + channel.listeners.clear(); } }); }); diff --git a/test/docs-snippets/setup.ts b/test/docs-snippets/setup.ts index 45eb31df9f..b8688815ca 100644 --- a/test/docs-snippets/setup.ts +++ b/test/docs-snippets/setup.ts @@ -1,4 +1,3 @@ -import { config } from 'dotenv'; import path from 'node:path'; import { afterAll, beforeAll } from 'vitest'; import { getServerClient } from './helpers/clients'; @@ -8,7 +7,12 @@ import { diffAppSnapshots, takeAppSnapshot } from './helpers/snapshot'; import type { AppSnapshot } from './helpers/snapshot'; import { retry } from './helpers/wait'; -config({ path: path.join(__dirname, '.env'), quiet: true }); +// Node's built-in `.env` loader; a missing file is left to the credentials check below. +try { + process.loadEnvFile(path.join(__dirname, '.env')); +} catch { + // no .env +} if (!process.env.STREAM_API_KEY || !process.env.STREAM_API_SECRET) { throw new Error( diff --git a/test/types/custom-events.ts b/test/types/custom-events.ts new file mode 100644 index 0000000000..189595e76c --- /dev/null +++ b/test/types/custom-events.ts @@ -0,0 +1,33 @@ +/** + * Compile-time contract for custom event types declared through `CustomEventTypes` augmentation. + * Type-checked, never executed. + * + * The augmentation is global to the program, so it also proves that `src/` still compiles once an + * app declares a custom event type (a bare `keyof CustomEventTypes` in the `Event` union used to + * break every `event.` read in the SDK). + */ +import type { Event, EventPayload, StreamChat } from 'stream-chat'; + +declare module 'stream-chat' { + interface CustomEventTypes { + friendship_request: true; + } + interface CustomEventData { + text?: string; + } +} + +declare const client: StreamChat; +declare const event: Event; + +export const eventType: string = event.type; + +client.on('friendship_request', (e) => { + const text: string | undefined = e.custom.text; + return text; +}); + +export const payload: EventPayload<'friendship_request'>['custom']['text'] = 'hi'; + +// @ts-expect-error -- an undeclared custom field is rejected +export const unknownField = ({} as EventPayload<'friendship_request'>).custom.unknown; diff --git a/test/types/custom-filters.ts b/test/types/custom-filters.ts new file mode 100644 index 0000000000..9775e4fe0c --- /dev/null +++ b/test/types/custom-filters.ts @@ -0,0 +1,21 @@ +/** + * Compile-time contract for filters over augmented custom data. Type-checked, never executed. + * + * An object-valued custom field must not become a typed `custom.` filter key: it would + * conflict with the primitive `custom.${string}` index signature and break every filter type built + * from the custom data (the SDK's own search sources included). + */ +import type { ChannelFilters } from 'stream-chat'; + +declare module 'stream-chat' { + interface CustomChannelData { + filter_test_color?: string; + filter_test_detail?: { user_id: number }; + } +} + +export const primitiveField: ChannelFilters = { 'custom.filter_test_color': 'red' }; +export const nestedField: ChannelFilters = { 'custom.filter_test_detail.user_id': 1 }; + +// @ts-expect-error -- a typed primitive custom field rejects a value of another type +export const wrongType: ChannelFilters = { 'custom.filter_test_color': { a: 1 } }; diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index f115d55366..d10c940d5f 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -4916,10 +4916,8 @@ describe('Channel provisional (created from members, without an id)', () => { it('refuses any other request before it is sent', async () => { const channel = fromMembers(); - await expect(channel.sendEvent({ event: { type: 'custom' } })).rejects.toThrow( - NO_ID, - ); - // a generated endpoint: it throws as it is called + // generated endpoints: they throw as they are called + expect(() => channel.sendEvent({ event: { type: 'custom' } })).toThrow(NO_ID); expect(() => channel.truncate()).toThrow(NO_ID); await expect(channel.queryMembers()).rejects.toThrow(NO_ID); await expect( @@ -5010,3 +5008,28 @@ describe('Channel query on a disposed instance', () => { expect(sendRequest).not.toHaveBeenCalled(); }); }); + +describe('Channel.updateAIState', () => { + it('sends the AI indicator fields under custom, where the API reads them', async () => { + const client = await getClientWithUser(); + const channel = client.channel('messaging', 'ai-channel'); + const sendEvent = vi.spyOn(client, 'sendEvent').mockResolvedValue({}); + + await channel.updateAIState('message-1', 'AI_STATE_THINKING', { ai_message: 'hi' }); + + expect(sendEvent).toHaveBeenCalledWith( + { id: 'ai-channel', type: 'messaging' }, + { + event: { + type: 'ai_indicator.update', + custom: { + ai_message: 'hi', + ai_state: 'AI_STATE_THINKING', + message_id: 'message-1', + }, + }, + }, + undefined, + ); + }); +}); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 5fba604650..54b21d4b99 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1625,6 +1625,33 @@ describe('StreamChat.queryThreads', () => { sinon.restore(); }); + + it('returns the registered instance of a thread, so activating it keeps it managed', async () => { + const client = await getClientWithUser(); + const parentMessage = generateMsg(); + const rawThread = generateThreadResponse( + mockChannelQueryResponse.channel, + parentMessage, + ); + sinon.stub(client, 'queryThreads').resolves({ threads: [rawThread] }); + sinon.stub(client, 'getThread').resolves({ thread: rawThread }); + + const { + threads: [registered], + } = await client.queryThreadsAndHydrate(); + registered.activate(); + + const { + threads: [requeried], + } = await client.queryThreadsAndHydrate(); + const fetched = await client.getThreadAndHydrate(parentMessage.id); + + expect(requeried).to.equal(registered); + expect(fetched).to.equal(registered); + expect(client.threads.get(parentMessage.id)).to.equal(registered); + + sinon.restore(); + }); }); describe('StreamChat.queryReactions', () => { diff --git a/test/unit/signing.test.js b/test/unit/signing.test.js index 10780f5c38..e2d892fc08 100644 --- a/test/unit/signing.test.js +++ b/test/unit/signing.test.js @@ -1,4 +1,4 @@ -import { UserFromToken } from '../../src'; +import { DevToken, StreamChat, UserFromToken } from '../../src'; import { describe, expect, it } from 'vitest'; @@ -22,4 +22,22 @@ describe('Signing', () => { expect(UserFromToken(token)).to.be.undefined; }); }); + + describe('DevToken', () => { + it('builds the same unsigned token as v9', () => { + expect(DevToken('amin')).to.equal( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYW1pbiJ9.devtoken', + ); + }); + + it('round-trips through UserFromToken, including non-Latin-1 ids', () => { + expect(UserFromToken(DevToken('amin'))).to.equal('amin'); + expect(() => DevToken('ünicode-✓')).not.to.throw(); + }); + + it('is exposed as client.devToken', () => { + const client = new StreamChat('key'); + expect(client.devToken('amin')).to.equal(DevToken('amin')); + }); + }); }); diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index 562aa4a009..d42ffffde0 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -40,7 +40,14 @@ describe('axiosParamsSerializer', () => { }, b: [1, 2, 3], }, - output: 'a=%7B%22b%22%3A1%2C%22c%22%3A2%2C%22d%22%3Anull%7D&b=%5B1%2C2%2C3%5D', + output: 'a=%7B%22b%22%3A1%2C%22c%22%3A2%2C%22d%22%3Anull%7D&b=1%2C2%2C3', + }, + { + input: { + ids: ['a', 'b'], + objects: [{ a: 1 }], + }, + output: 'ids=a%2Cb&objects=%5B%7B%22a%22%3A1%7D%5D', }, ]; it('should serialize params', () => { diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index bc21e5757a..57aff64ec8 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -68,7 +68,7 @@ Two things to know when rewriting call sites: The following `StreamChat` methods no longer exist. All were server-side or admin-only. Rewrites should either delete the call site or move it to the server SDK: -`updateAppSettings`, `revokeUserToken`, `revokeUsersToken`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `createToken`, `devToken`, user-groups mutations (`createUserGroup` / `getUserGroup` / `searchUserGroups` / `updateUserGroup` / `deleteUserGroup` / `addUserGroupMembers` / `removeUserGroupMembers`) — the read path is renamed, see below, `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences`, `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `queryFutureChannelBans`-write paths, `getHookEvents`, `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser`, `reactivateUsers`, `deactivateUser`, `deactivateUsers`, `exportUser`, `getSharedLocations`, `translate`, `translateMessage`, `updateFlags`, `queryCampaigns`, `_createImportURL`, `_createImport`, `_getImport`, `_listImports`, `commitMessage`, `queryTeamUsageStats`, `updateLocation`, `updateChannelsBatch`, `deletePredefinedFilter`, `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns`, hand-rolled reminder client methods (`createReminder`/`updateReminder`/`deleteReminder` — see note under `Reminder` handling; the inherited `queryReminders` from `ChatApi` remains but with the generated request shape, not the v9 `QueryRemindersOptions`), `createCommand`/`getCommand`/`updateCommand`/`deleteCommand`/`listCommands`/`createChannelType`/`getChannelType`/`updateChannelType`/`deleteChannelType`/`listChannelTypes`/`exportChannel`/`exportChannels`/`exportUsers`/`getExportChannelStatus`/`getTask`/`enrichURL`/`sendUserCustomEvent`, `deleteChannels`, `deleteUsers`, `createRole`/`listRoles`/`deleteRole` (only `searchRoles` remains, inherited), `getPermission`/`createPermission`/`updatePermission`/`deletePermission`/`listPermissions`, `getBlockList` (only `listBlockLists`/`createBlockList`/`updateBlockList`/`deleteBlockList` remain, inherited), `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns` (removed outright — see below), `campaign`, `segment`, `channelBatchUpdater`, `validateServerSideAuth`, `createSegment`, `createUserSegment`, `createChannelSegment`, `getSegment`, `updateSegment`, `addSegmentTargets`, `querySegmentTargets`, `removeSegmentTargets`, `querySegments`, `deleteSegment`, `segmentTargetExists`, `createCampaign`, `getCampaign`, `startCampaign`, `updateCampaign`, `deleteCampaign`, `stopCampaign`, `_normalizeDate`. Note: `queryDrafts`, `queryPolls`, `queryPollVotes`, `queryMessageFlags`, and `markChannelsDelivered` — all of which were hand-rolled in v9 — now come from `ChatApi` inheritance with generated request shapes; they still exist on `client`. +`updateAppSettings`, `revokeUserToken`, `revokeUsersToken`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `createToken`, user-groups mutations (`createUserGroup` / `getUserGroup` / `searchUserGroups` / `updateUserGroup` / `deleteUserGroup` / `addUserGroupMembers` / `removeUserGroupMembers`) — the read path is renamed, see below, `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences`, `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `queryFutureChannelBans`-write paths, `getHookEvents`, `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser`, `reactivateUsers`, `deactivateUser`, `deactivateUsers`, `exportUser`, `getSharedLocations`, `translate`, `translateMessage`, `updateFlags`, `queryCampaigns`, `_createImportURL`, `_createImport`, `_getImport`, `_listImports`, `commitMessage`, `queryTeamUsageStats`, `updateLocation`, `updateChannelsBatch`, `deletePredefinedFilter`, `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns`, hand-rolled reminder client methods (`createReminder`/`updateReminder`/`deleteReminder` — see note under `Reminder` handling; the inherited `queryReminders` from `ChatApi` remains but with the generated request shape, not the v9 `QueryRemindersOptions`), `createCommand`/`getCommand`/`updateCommand`/`deleteCommand`/`listCommands`/`createChannelType`/`getChannelType`/`updateChannelType`/`deleteChannelType`/`listChannelTypes`/`exportChannel`/`exportChannels`/`exportUsers`/`getExportChannelStatus`/`getTask`/`enrichURL`/`sendUserCustomEvent`, `deleteChannels`, `deleteUsers`, `createRole`/`listRoles`/`deleteRole` (only `searchRoles` remains, inherited), `getPermission`/`createPermission`/`updatePermission`/`deletePermission`/`listPermissions`, `getBlockList` (only `listBlockLists`/`createBlockList`/`updateBlockList`/`deleteBlockList` remain, inherited), `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns` (removed outright — see below), `campaign`, `segment`, `channelBatchUpdater`, `validateServerSideAuth`, `createSegment`, `createUserSegment`, `createChannelSegment`, `getSegment`, `updateSegment`, `addSegmentTargets`, `querySegmentTargets`, `removeSegmentTargets`, `querySegments`, `deleteSegment`, `segmentTargetExists`, `createCampaign`, `getCampaign`, `startCampaign`, `updateCampaign`, `deleteCampaign`, `stopCampaign`, `_normalizeDate`. Note: `queryDrafts`, `queryPolls`, `queryPollVotes`, `queryMessageFlags`, and `markChannelsDelivered` — all of which were hand-rolled in v9 — now come from `ChatApi` inheritance with generated request shapes; they still exist on `client`. ### Renamed / signature-changed @@ -746,7 +746,7 @@ import { verifySignature, verifyAndParseWebhook, parseSqs, parseSns } from 'stre // v10 — nothing to import. Move the handler to @stream-io/node-sdk. ``` -Also gone from `stream-chat`, from the same module: `verifySignature`, `CheckSignature`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, and `InvalidWebhookErrorMessages`. `signing.ts` now exports exactly one function, `UserFromToken` — the client-side JWT payload decoder. The JWT minting helpers (`JWTUserToken`, `JWTServerToken`, `DevToken`) are gone too. +Also gone from `stream-chat`, from the same module: `verifySignature`, `CheckSignature`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, and `InvalidWebhookErrorMessages`. `signing.ts` now exports `UserFromToken` (the client-side JWT payload decoder) and `DevToken` (the unsigned development token, also `client.devToken(userId)`; it needs no secret). The secret-signing JWT helpers (`JWTUserToken`, `JWTServerToken`) are gone. Webhook verification is inherently server-side work: it needs the API secret, which v10 refuses to hold. Port the handler to [`@stream-io/node-sdk`](https://github.com/GetStream/stream-node), which keeps the v9 method names on the client (`client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, `client.parseSns`) and takes the secret from construction. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md#webhook--sns--sqs) for the mapping table. @@ -783,7 +783,7 @@ Both are `@internal`. The consumer-visible effects: - `channel._update(payload)` — REMOVED. Use `channel.update(request)` (inherited from `ChannelApi`). - `channel.updateMemberPartial(updates, options?: { userId? })` — REMOVED (v9 wrapper). Use the inherited `channel.updateMemberPartial(request?)` — same name, generated shape. - `channel.partialUpdateMember(user_id, updates)` — REMOVED. Use `channel.updateMemberPartial({ user_id, ...updates })`. -- `channel.sendEvent(event)` — replaced by `channel.sendEvent(request: { event })` (override). +- `channel.sendEvent(event)` — replaced by `channel.sendEvent(request: { event })`, the generated request (`event: { type, parent_id?, custom? }`). - `channel.getConfig()` — **REMOVED**. Use the `channel.serverConfig` **getter**, which returns the same value: this channel's server configuration (`ChannelConfigWithInfo`) — mostly type-level, but narrowed by the channel's own `config_overrides` where it has any. It is a property now, not a call — `channel.getConfig()?.uploads` becomes `channel.serverConfig?.uploads`. If you mock it in tests, note that `vi.fn()` cannot stand in for a getter. - **Not** to be confused with `channel.config`, which is new and different: the channel's _resolved_ configuration, where a handful of server flags have been combined with what you registered through `client.config`. See the table under "Composer & attachment shape" for which fields live where. @@ -821,10 +821,15 @@ local state. `channel.messageOperations.send` is the full send — see channel.sendEvent(event: Event); // v10 -channel.sendEvent(request: { event: Event }); -// Event now unions the generated WSEvent, the SDK-only LocalEvent, and keyof CustomEventTypes. +channel.sendEvent(request: { event: EventRequest }, requestOptions?); +// EventRequest = { type: string; parent_id?: string; custom?: CustomEventData } ``` +Event data goes under `custom` (`{ event: { type: 'friendship_request', custom: { text } } }`): top-level +fields other than `type` and `parent_id` are dropped by the API. The server stamps `created_at` and `user`. +It is the generated method, so on a channel without an id it throws synchronously (`isn't yet created`) +rather than returning a rejected promise. + #### `channel.search` ```ts @@ -985,13 +990,16 @@ channel.sendAction(messageID, formData); channel.keystroke(parent_id?, options?: { user_id }); channel.stopTyping(parent_id?, options?: { user_id }); -// v10 — same shape; positional rename to `messageId` / `parentId` +// v10 — positional rename to `messageId` / `parentId`; keystroke / stopTyping drop `options` channel.muteStatus(); // same return shape channel.sendAction(messageId, formData); -channel.keystroke(parentId?, options?); -channel.stopTyping(parentId?, options?); +channel.keystroke(parentId?, requestOptions?); +channel.stopTyping(parentId?, requestOptions?); ``` +`keystroke` and `stopTyping` lost their `options?: { user_id }` parameter: sending typing events on behalf of +another user is server-side only. The second argument is now `requestOptions`. + #### `channel.markRead` / `channel.markAsReadRequest` **Semantic swap** — read carefully: diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index e902445658..6e2ba470d0 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -120,7 +120,7 @@ Any consumer doing `import { Campaign, Segment, ChannelBatchUpdater, EVENT_MAP, ### `./signing` is down to one export -The barrel is still there, but it holds a **single** function: `UserFromToken`. Everything else it used to carry — `JWTUserToken`, `JWTServerToken`, `DevToken`, `verifySignature`, `CheckSignature`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, `InvalidWebhookErrorMessages` — needed the API secret or a Node builtin, and went out with the server-side surface. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md). +The barrel is still there, but it holds only `UserFromToken` and `DevToken` (also exposed as `client.devToken(userId)`). Everything else it used to carry — `JWTUserToken`, `JWTServerToken`, `verifySignature`, `CheckSignature`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, `InvalidWebhookErrorMessages` — needed the API secret or a Node builtin, and went out with the server-side surface. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md). `UserFromToken` itself changed implementation: it decodes the JWT payload with the global `atob` instead of the removed `base64-js` helpers. It runs on the `connectUser` path, so older React Native / Hermes targets — Hermes only gained `atob` / `btoa` around React Native 0.74 — must install a base64 polyfill before the first `connectUser`, or connecting throws `ReferenceError: atob is not defined`. Verify with `typeof atob` on the target rather than by version number; browsers, Node 16+, Bun, and Deno all have it natively. @@ -1487,7 +1487,7 @@ For any of these that survive as a generated shape, the replacement is the gener For each source file that touches the SDK: -1. **Delete removed imports.** `EVENT_MAP`, `Campaign*`, `Segment*`, `ChannelBatchUpdater`, `encodeBase64`, `decodeBase64`, the webhook / JWT helpers from `./signing` (`verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, `CheckSignature`, `InvalidWebhookError`, `JWTUserToken`, `JWTServerToken`, `DevToken`), `Role` (rename), `MessageResponseBase`, `FormatMessageResponse`, `PredefinedFilterSort(Param)`, and any of the removed type utilities. `Event`, `CustomEventTypes`, and `UserFromToken` are kept — do not delete them. +1. **Delete removed imports.** `EVENT_MAP`, `Campaign*`, `Segment*`, `ChannelBatchUpdater`, `encodeBase64`, `decodeBase64`, the webhook / JWT helpers from `./signing` (`verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, `CheckSignature`, `InvalidWebhookError`, `JWTUserToken`, `JWTServerToken`), `Role` (rename), `MessageResponseBase`, `FormatMessageResponse`, `PredefinedFilterSort(Param)`, and any of the removed type utilities. `Event`, `CustomEventTypes`, `UserFromToken` and `DevToken` are kept — do not delete them. 2. **Rename `Role` → `RoleName`** at every import + annotation site. 3. **Rewrite event-handler callback types where needed.** `Event` is still valid (its union widened) — prefer `EventPayload<'…'>` for narrowed access. Custom event-type augmentation still goes on `CustomEventTypes`, unchanged from v9. Rename any imports of the plural `EventTypes` to the singular `EventType`. 4. **Guard `channel.state.membership` reads** with `?.` — it's `undefined` on freshly constructed channels. diff --git a/v9-to-v10-migration-guide-server-side.md b/v9-to-v10-migration-guide-server-side.md index adec5a1047..ae32e0a881 100644 --- a/v9-to-v10-migration-guide-server-side.md +++ b/v9-to-v10-migration-guide-server-side.md @@ -22,8 +22,8 @@ Any one of these means yes — this guide applies to you: - You construct `stream-chat` with a non-empty second positional argument: `new StreamChat(key, 'secret', ...)` — including via env vars like `process.env.STREAM_SECRET` / `STREAM_API_SECRET`. - You read `client.secret` or call `client._isUsingServerAuth()`. - You pass `allowServerSideConnect: true` in `StreamChatOptions`. -- You call any admin method: `createToken`, `devToken`, `revokeUserToken(s)`, `updateAppSettings`, `getAppSettings`, `deleteUser`, `partialUpdateUser(s)`, `restoreUsers`, `deactivateUser(s)`, `reactivateUser(s)`, `exportUser(s)`, `createChannelType`, `updateChannelType`, `deleteChannelType`, `listChannelTypes`, `getChannelType`, `createCommand` / `updateCommand` / `deleteCommand` / `listCommands` / `getCommand`, `createRole` / `deleteRole` / `listRoles`, `createPermission` / `updatePermission` / `deletePermission` / `getPermission` / `listPermissions`, `createBlockList` / `updateBlockList` / `deleteBlockList` / `getBlockList` / `listBlockLists` / `importBlockList`, `upsertPushProvider` / `deletePushProvider` / `listPushProviders`, `checkPush` / `checkSNS` / `checkSQS`, `createImport` / `createImportURL` / `getImport` / `listImports`, retention-policy admin (`setRetentionPolicy` / `deleteRetentionPolicy` / `getRetentionPolicy` / `getRetentionPolicyRuns`), moderation config admin, campaign / segment methods. -- You call `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, or `CheckSignature` — the JWT helpers `JWTUserToken` / `JWTServerToken` / `DevToken` — these were all removed from `stream-chat/signing`. +- You call any admin method: `createToken`, `revokeUserToken(s)`, `updateAppSettings`, `getAppSettings`, `deleteUser`, `partialUpdateUser(s)`, `restoreUsers`, `deactivateUser(s)`, `reactivateUser(s)`, `exportUser(s)`, `createChannelType`, `updateChannelType`, `deleteChannelType`, `listChannelTypes`, `getChannelType`, `createCommand` / `updateCommand` / `deleteCommand` / `listCommands` / `getCommand`, `createRole` / `deleteRole` / `listRoles`, `createPermission` / `updatePermission` / `deletePermission` / `getPermission` / `listPermissions`, `createBlockList` / `updateBlockList` / `deleteBlockList` / `getBlockList` / `listBlockLists` / `importBlockList`, `upsertPushProvider` / `deletePushProvider` / `listPushProviders`, `checkPush` / `checkSNS` / `checkSQS`, `createImport` / `createImportURL` / `getImport` / `listImports`, retention-policy admin (`setRetentionPolicy` / `deleteRetentionPolicy` / `getRetentionPolicy` / `getRetentionPolicyRuns`), moderation config admin, campaign / segment methods. +- You call `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, or `CheckSignature` — the JWT helpers `JWTUserToken` / `JWTServerToken` — these were all removed from `stream-chat/signing`. (`DevToken`, which needs no secret, stays.) - You import from removed barrel paths: `stream-chat/dist/.../campaign`, `.../segment`, `.../channel_batch_updater`, `.../events`, `.../base64`. - You upload files from Node — `channel.sendFile(fs.createReadStream(...))`, `client.uploadFile(buffer, ...)`, or anything else that hands a `Buffer` / readable stream to the upload methods. See [Uploads from Node are gone](#uploads-from-node-are-gone). - You pass `user_id` overrides to per-user methods (`banUser`, `blockUser`, `muteUser`, `flagMessage`, `flagUser`) — those overrides are gone from `stream-chat@10` because they only made sense server-side. @@ -76,14 +76,13 @@ Only chat / user / moderation surfaces are enumerated here — the guide focuses ### Token generation -| v9 (`stream-chat`) | v10 (`@stream-io/node-sdk`) | Notes | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `client.createToken(userId, exp?, iat?)` | `client.generateUserToken({ user_id, exp?, iat?, validity_in_seconds? })` | Old name kept as deprecated `createToken(userId, exp?, iat?)`. | -| `client.createToken(userId)` (no expiry) | `client.generatePermanentUserToken({ user_id })` | Explicitly permanent — no `exp` claim. | -| `client.devToken(userId)` | _Not exposed._ Use `generateUserToken` in tests, or sign your own with a hardcoded secret. | Dev tokens were a v9-only shortcut. | -| `client.createCallToken(userId, cids)` | `client.generateCallToken({ user_id, call_cids, role?, exp?, iat?, validity_in_seconds? })` | Old name kept as deprecated `createCallToken(userIdOrObject, cids, exp?, iat?)`. | -| `client.revokeUserToken(id, before?)` / `revokeUsersToken(...)` | Not on node-sdk yet — mint with a fresh `iat` / rotate the app secret. | | -| `JWTUserToken(secret, userId, extra?, opts?)` / `JWTServerToken(secret, opts?)` / `DevToken(userId)` (formerly importable from `stream-chat` or `stream-chat/signing`) | _Removed from `stream-chat`._ Use `client.generateUserToken` etc. — the node SDK signs internally with the secret you passed at construction. | | +| v9 (`stream-chat`) | v10 (`@stream-io/node-sdk`) | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `client.createToken(userId, exp?, iat?)` | `client.generateUserToken({ user_id, exp?, iat?, validity_in_seconds? })` | Old name kept as deprecated `createToken(userId, exp?, iat?)`. | +| `client.createToken(userId)` (no expiry) | `client.generatePermanentUserToken({ user_id })` | Explicitly permanent — no `exp` claim. | +| `client.createCallToken(userId, cids)` | `client.generateCallToken({ user_id, call_cids, role?, exp?, iat?, validity_in_seconds? })` | Old name kept as deprecated `createCallToken(userIdOrObject, cids, exp?, iat?)`. | +| `client.revokeUserToken(id, before?)` / `revokeUsersToken(...)` | Not on node-sdk yet — mint with a fresh `iat` / rotate the app secret. | | +| `JWTUserToken(secret, userId, extra?, opts?)` / `JWTServerToken(secret, opts?)` (formerly importable from `stream-chat` or `stream-chat/signing`) | _Removed from `stream-chat`._ Use `client.generateUserToken` etc. — the node SDK signs internally with the secret you passed at construction. | | ### Webhook / SNS / SQS @@ -309,7 +308,7 @@ await client.uploadFile({ - `client.secret` / `client._isUsingServerAuth()` — construct the client with a secret and check for its presence yourself if you must. - `client.validateServerSideAuth()` — the node SDK is always server-side. - Per-call `user_id` overrides on `banUser` / `blockUser` / `flagMessage` / `flagUser` — pass `user_id` in the request payload (all node-sdk mutations that act on behalf of a user take an explicit `user_id`). -- `JWTUserToken` / `JWTServerToken` / `DevToken` / `verifySignature` / `CheckSignature` / `InvalidWebhookError` / `InvalidWebhookErrorMessages` re-exported from `stream-chat` — all gone. `signing.ts` on `stream-chat` now exposes only `UserFromToken` (a client-side JWT decoder). Consume equivalents from `@stream-io/node-sdk`. +- `JWTUserToken` / `JWTServerToken` / `verifySignature` / `CheckSignature` / `InvalidWebhookError` / `InvalidWebhookErrorMessages` re-exported from `stream-chat` — all gone. `signing.ts` on `stream-chat` now exposes only `UserFromToken` (a client-side JWT decoder) and `DevToken`. Consume equivalents from `@stream-io/node-sdk`. - `stream-chat` runtime deps that used to underwrite the server surface — `jsonwebtoken`, `ws`, `isomorphic-ws`, `base64-js`, `form-data` — have been dropped from `package.json#dependencies`. `stream-chat@10` imports none of them; if a bundler still resolves one out of your tree, it is a stale lockfile, not the SDK. - `@types/jsonwebtoken` and `@types/ws` were v9 **runtime** dependencies, so their types leaked into your project for free. They are gone. Any of your own code annotated with `jwt.Secret`, `jwt.SignOptions`, `WebSocket.CloseEvent`, `WebSocket.Data` etc. now needs those packages in your own `devDependencies`. - The keep-alive `https.Agent` that v9 installed on the axios instance in Node — see [HTTP keep-alive is no longer configured for you](#http-keep-alive-is-no-longer-configured-for-you). @@ -585,7 +584,7 @@ Before shipping the migrated backend: - [ ] `client.generateUserToken({ user_id: 'demo' })` returns a JWT that `stream-chat`'s `connectUser` accepts against the same API key. - [ ] `client.verifyAndParseWebhook(rawBody, signature)` round-trips a captured production event. - [ ] An admin call (`client.chat.queryChannels(...)`, `client.upsertUsers(...)`) succeeds against the dashboard. -- [ ] All uses of `verifyWebhook` / `parseSqs` / `parseSns` / `createToken` / `JWTUserToken` / `DevToken` are imported from `@stream-io/node-sdk`, not `stream-chat`. +- [ ] All uses of `verifyWebhook` / `parseSqs` / `parseSns` / `createToken` / `JWTUserToken` are imported from `@stream-io/node-sdk`, not `stream-chat`. - [ ] No remaining imports from `stream-chat/dist/.../campaign`, `.../segment`, `.../channel_batch_updater`, `.../events`, `.../base64`. - [ ] If you kept a `stream-chat@10` client for WS: it is constructed **without** a secret, and `allowServerSideConnect: true` is set. - [ ] If you deploy on Node 18/20: `WebSocketImpl` is wired to the `ws` package and `ws` is pinned in `dependencies`. On Node 22+, no `WebSocketImpl` is needed. From 1e2e8dde038b08b29e3b74701ec41aa93d089ef0 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 14 Oct 2026 08:22:58 -0500 Subject: [PATCH 02/10] test: add custom filters test --- test/types/custom-filters.ts | 47 +++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/test/types/custom-filters.ts b/test/types/custom-filters.ts index 9775e4fe0c..80cb21b123 100644 --- a/test/types/custom-filters.ts +++ b/test/types/custom-filters.ts @@ -10,12 +10,57 @@ import type { ChannelFilters } from 'stream-chat'; declare module 'stream-chat' { interface CustomChannelData { filter_test_color?: string; - filter_test_detail?: { user_id: number }; + filter_test_detail?: { user_id: number; nested?: { label: string } }; + filter_test_count?: number; + filter_test_flag?: boolean; + filter_test_date?: Date; + filter_test_level?: 'low' | 'high'; + filter_test_tags?: string[]; } } export const primitiveField: ChannelFilters = { 'custom.filter_test_color': 'red' }; export const nestedField: ChannelFilters = { 'custom.filter_test_detail.user_id': 1 }; +// Every primitive kind keeps its own type, alongside object-valued fields on the same interface. +export const mixedFields: ChannelFilters = { + 'custom.filter_test_color': { $in: ['red', 'blue'] }, + 'custom.filter_test_count': { $gt: 1 }, + 'custom.filter_test_flag': true, + 'custom.filter_test_date': { $lt: new Date() }, + 'custom.filter_test_level': 'low', + 'custom.filter_test_detail.nested.label': { $autocomplete: 'x' }, +}; +export const nullableEq: ChannelFilters = { 'custom.filter_test_color': { $eq: null } }; +export const inLogical: ChannelFilters = { + $or: [ + { 'custom.filter_test_count': 1 }, + { 'custom.filter_test_detail.user_id': { $gte: 2 } }, + ], +}; +// Object- and array-valued fields fall back to the primitive index signature. +export const objectExists: ChannelFilters = { + 'custom.filter_test_detail': { $exists: true }, +}; +export const arrayContains: ChannelFilters = { + 'custom.filter_test_tags': { $contains: 'a' }, +}; + // @ts-expect-error -- a typed primitive custom field rejects a value of another type export const wrongType: ChannelFilters = { 'custom.filter_test_color': { a: 1 } }; +// @ts-expect-error -- a number field rejects a string operand +export const wrongNumber: ChannelFilters = { 'custom.filter_test_count': { $gt: 'x' } }; +// @ts-expect-error -- a boolean field rejects a string +export const wrongBoolean: ChannelFilters = { 'custom.filter_test_flag': 'true' }; +// @ts-expect-error -- a Date field rejects a string +export const wrongDate: ChannelFilters = { 'custom.filter_test_date': 'yesterday' }; +// @ts-expect-error -- a literal-union field rejects other strings +export const wrongLiteral: ChannelFilters = { 'custom.filter_test_level': 'medium' }; +export const wrongNested: ChannelFilters = { + // @ts-expect-error -- a nested path rejects an object value + 'custom.filter_test_detail.user_id': { a: 1 }, +}; +export const wrongInLogical: ChannelFilters = { + // @ts-expect-error -- typed fields are still checked inside logical operators + $or: [{ 'custom.filter_test_count': 'x' }], +}; From 03fda4a048d3cc2ac5c2b2ba9c0434ef81110baa Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 14 Oct 2026 15:25:18 -0500 Subject: [PATCH 03/10] chore: add platfrom tests too --- src/gen/models/index.ts | 15 + test/docs-snippets/.env.example | 2 + test/docs-snippets/README.md | 34 +- .../docs-snippets-agent-prompt.md | 32 +- test/docs-snippets/docs-snippets-todo.md | 53 ++- test/docs-snippets/learnings.md | 14 +- ...1-client_tokens_and_authentication.test.ts | 12 +- .../02-init_and_users.test.ts | 18 +- .../02-init_and_users/04-user_groups.test.ts | 26 +- .../05-authless_users.test.ts | 12 +- .../03-channels/01-creating_channels.test.ts | 16 +- .../03-channels/02-query_channels.test.ts | 34 +- .../03-channels/03-channel_update.test.ts | 12 +- .../03-channels/04-query_members.test.ts | 14 +- .../03-channels/05-channel_pagination.test.ts | 12 +- .../03-channels/06-channel_members.test.ts | 28 +- .../08-archiving.test.ts | 12 +- .../07-channel_management/09-pinning.test.ts | 12 +- .../07-channel_management/10-muting.test.ts | 20 +- .../07-channel_management/11-hiding.test.ts | 14 +- .../12-disabling.test.ts | 10 +- .../07-channel_management/13-deleting.test.ts | 12 +- .../07-channel_management/14-freezing.test.ts | 14 +- .../15-truncating.test.ts | 14 +- .../16-channel_invites.test.ts | 22 +- .../04-messages/01-send_message.test.ts | 30 +- .../04-messages/02-file_uploads.test.ts | 22 +- .../{ => chat}/04-messages/03-threads.test.ts | 40 +-- .../04-messages/04-send_reaction.test.ts | 18 +- .../04-messages/05-pinned_messages.test.ts | 16 +- .../{ => chat}/04-messages/06-search.test.ts | 16 +- .../04-messages/07-silent_messages.test.ts | 16 +- .../04-messages/09-message_reminders.test.ts | 26 +- .../04-messages/10-message_receipts.test.ts | 18 +- .../{ => chat}/05-features/02-events.test.ts | 24 +- .../{ => chat}/05-features/03-unread.test.ts | 28 +- .../05-features/04-typing_indicators.test.ts | 14 +- .../05-features/05-presence_format.test.ts | 22 +- .../05-features/07-polls_api.test.ts | 44 +-- .../05-features/08-location_sharing.test.ts | 26 +- .../05-features/09-translation.test.ts | 20 +- .../11-slow_mode_and_throttling.test.ts | 16 +- .../05-features/10-advanced/12-drafts.test.ts | 22 +- .../10-advanced/13-private_messaging.test.ts | 16 +- .../10-advanced/15-pending_messages.test.ts | 14 +- .../12-best_practices/02-moderation.test.ts | 36 +- .../01-plain_js_introduction.test.ts | 20 +- .../04-multi-tenancy.test.ts | 115 +++++++ .../01-webhooks.test.ts | 51 +++ .../04-push-devices.test.ts | 99 ++++++ .../05-push-preferences.test.ts | 323 ++++++++++++++++++ test/docs-snippets/sync/docs-sync.test.ts | 28 +- test/docs-snippets/sync/parse.ts | 109 ++++-- test/types/team-filter.ts | 15 + 54 files changed, 1226 insertions(+), 482 deletions(-) rename test/docs-snippets/pages/{ => chat}/02-init_and_users/01-client_tokens_and_authentication.test.ts (87%) rename test/docs-snippets/pages/{ => chat}/02-init_and_users/02-init_and_users.test.ts (81%) rename test/docs-snippets/pages/{ => chat}/02-init_and_users/04-user_groups.test.ts (86%) rename test/docs-snippets/pages/{ => chat}/02-init_and_users/05-authless_users.test.ts (77%) rename test/docs-snippets/pages/{ => chat}/03-channels/01-creating_channels.test.ts (82%) rename test/docs-snippets/pages/{ => chat}/03-channels/02-query_channels.test.ts (82%) rename test/docs-snippets/pages/{ => chat}/03-channels/03-channel_update.test.ts (85%) rename test/docs-snippets/pages/{ => chat}/03-channels/04-query_members.test.ts (90%) rename test/docs-snippets/pages/{ => chat}/03-channels/05-channel_pagination.test.ts (86%) rename test/docs-snippets/pages/{ => chat}/03-channels/06-channel_members.test.ts (85%) rename test/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/08-archiving.test.ts (81%) rename test/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/09-pinning.test.ts (85%) rename test/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/10-muting.test.ts (83%) rename test/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/11-hiding.test.ts (88%) rename test/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/12-disabling.test.ts (88%) rename test/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/13-deleting.test.ts (82%) rename test/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/14-freezing.test.ts (81%) rename test/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/15-truncating.test.ts (84%) rename test/docs-snippets/pages/{ => chat}/03-channels/07-channel_management/16-channel_invites.test.ts (80%) rename test/docs-snippets/pages/{ => chat}/04-messages/01-send_message.test.ts (83%) rename test/docs-snippets/pages/{ => chat}/04-messages/02-file_uploads.test.ts (89%) rename test/docs-snippets/pages/{ => chat}/04-messages/03-threads.test.ts (87%) rename test/docs-snippets/pages/{ => chat}/04-messages/04-send_reaction.test.ts (88%) rename test/docs-snippets/pages/{ => chat}/04-messages/05-pinned_messages.test.ts (89%) rename test/docs-snippets/pages/{ => chat}/04-messages/06-search.test.ts (91%) rename test/docs-snippets/pages/{ => chat}/04-messages/07-silent_messages.test.ts (83%) rename test/docs-snippets/pages/{ => chat}/04-messages/09-message_reminders.test.ts (92%) rename test/docs-snippets/pages/{ => chat}/04-messages/10-message_receipts.test.ts (88%) rename test/docs-snippets/pages/{ => chat}/05-features/02-events.test.ts (90%) rename test/docs-snippets/pages/{ => chat}/05-features/03-unread.test.ts (84%) rename test/docs-snippets/pages/{ => chat}/05-features/04-typing_indicators.test.ts (91%) rename test/docs-snippets/pages/{ => chat}/05-features/05-presence_format.test.ts (89%) rename test/docs-snippets/pages/{ => chat}/05-features/07-polls_api.test.ts (87%) rename test/docs-snippets/pages/{ => chat}/05-features/08-location_sharing.test.ts (85%) rename test/docs-snippets/pages/{ => chat}/05-features/09-translation.test.ts (88%) rename test/docs-snippets/pages/{ => chat}/05-features/10-advanced/11-slow_mode_and_throttling.test.ts (86%) rename test/docs-snippets/pages/{ => chat}/05-features/10-advanced/12-drafts.test.ts (87%) rename test/docs-snippets/pages/{ => chat}/05-features/10-advanced/13-private_messaging.test.ts (81%) rename test/docs-snippets/pages/{ => chat}/05-features/10-advanced/15-pending_messages.test.ts (85%) rename test/docs-snippets/pages/{ => chat}/12-best_practices/02-moderation.test.ts (84%) rename test/docs-snippets/pages/{ => chat}/javascript/01-quick_start/01-plain_js_introduction.test.ts (79%) create mode 100644 test/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts create mode 100644 test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts create mode 100644 test/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts create mode 100644 test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts create mode 100644 test/types/team-filter.ts diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 0ba3a24fb8..b328747f3b 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -48,6 +48,11 @@ export type Filters< }> | ('$eq' extends FCHelper[Property]['operators'] ? FilterValue[Property], '$eq'> + : never) + // `allowEmpty` marks a key whose empty filter (`{}`) has a meaning of its own, + // e.g. `team: {}` (all teams) in a multi-tenant channel query. + | (FCHelper[Property] extends { allowEmpty: true } + ? Record : never); }>; @@ -6818,9 +6823,14 @@ export interface QueryChannelsRequest { operators: '$eq'; }; + /** + * With multi-tenancy, client-side queries without a `team` filter only return channels of + * the user's teams; an empty filter (`team: {}`) queries across all teams. + */ team: { type: string; operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; + allowEmpty: true; }; type: { @@ -8833,9 +8843,14 @@ export interface SearchPayload { operators: '$eq'; }; + /** + * With multi-tenancy, client-side queries without a `team` filter only return channels of + * the user's teams; an empty filter (`team: {}`) queries across all teams. + */ team: { type: string; operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; + allowEmpty: true; }; type: { diff --git a/test/docs-snippets/.env.example b/test/docs-snippets/.env.example index dd6ee8b875..11c121cbd5 100644 --- a/test/docs-snippets/.env.example +++ b/test/docs-snippets/.env.example @@ -1,3 +1,5 @@ # Copy to .env (gitignored) and fill in the credentials of the docs test app. STREAM_API_KEY= STREAM_API_SECRET= +# Name of a Firebase push provider on the app (push devices tests; skipped when empty). +STREAM_PUSH_PROVIDER_NAME= diff --git a/test/docs-snippets/README.md b/test/docs-snippets/README.md index 4e689bfea5..cd794e5c00 100644 --- a/test/docs-snippets/README.md +++ b/test/docs-snippets/README.md @@ -1,14 +1,16 @@ # Docs snippet tests -Typed tests that run the client-side JavaScript snippets of the chat docs (the pages of the -JavaScript sidebar, `getstream.io/content/docs/_sidebars/[chat][javascript].json`) against a real Stream app. -Only fences labelled `JavaScript` are in scope (docs fences must always have a label): they are fixed when they are -wrong or contain server-side code. `Node.js` fences are out of scope and are neither tested nor changed. +Typed tests that run the client-side JavaScript snippets of the chat and platform docs (the pages of the +sidebars `getstream.io/content/docs/_sidebars/[chat][javascript].json` and `[platform][default].json`) against a +real Stream app. Only fences labelled `JavaScript` are in scope (docs fences must always have a label): they are fixed +when they are wrong or contain server-side code. `Node.js` fences (server-side, with or without `@stream-io/node-sdk`) are out of scope and are neither tested nor changed. +Platform pages also cover other products: their Feeds fences (`@stream-io/feeds-client` and its React / React Native +hooks) are excluded with `EXCLUDED_SECTIONS` (see below). ```sh -cp test/docs-snippets/.env.example test/docs-snippets/.env # fill in STREAM_API_KEY / STREAM_API_SECRET +cp test/docs-snippets/.env.example test/docs-snippets/.env # fill in STREAM_API_KEY / STREAM_API_SECRET (and STREAM_PUSH_PROVIDER_NAME) yarn test-docs # all docs tests -yarn test-docs pages/04-messages/01-send_message.test.ts # one file +yarn test-docs pages/chat/04-messages/01-send_message.test.ts # one file yarn types-docs # typecheck the docs tests yarn test-docs-sync # test regions == docs fences (no network) yarn test-docs-sweep # remove docs-test leftovers older than 30 min (DOCS_SWEEP_DRY_RUN=1 to list only, DOCS_SWEEP_MIN_AGE=0 for all) @@ -20,7 +22,8 @@ These tests hit the network and are not part of `yarn test`. ## Layout ``` -pages//.test.ts # user connected with a user token (tab label "JavaScript") +pages/chat//.test.ts # chat docs; user connected with a user token (tab label "JavaScript") +pages/platform//.test.ts # platform docs, same conventions helpers/ # clients (stream-chat-js user clients, node-sdk server client), unique ids, # cleanup registry, server-side helpers, waits, leak check, app snapshot setup.ts # .env loading, per-file leak check and app-settings drift check @@ -32,8 +35,10 @@ docs-snippets-agent-prompt.md # prompt for an agent that covers on learnings.md # lessons from earlier agent runs, read before working on a page ``` -The path mirrors the docs path, e.g. `_default/04-messages/01-send_message.md` maps to -`pages/04-messages/01-send_message.test.ts`. Pages under `chat/javascript/` go under `pages/javascript/...`. +The path mirrors the docs path relative to `content/docs/`, without `_default/`: +`chat/_default/04-messages/01-send_message.md` maps to `pages/chat/04-messages/01-send_message.test.ts`, +`chat/javascript/...` to `pages/chat/javascript/...` and `platform/02-identity-and-access/04-multi-tenancy.md` to +`pages/platform/02-identity-and-access/04-multi-tenancy.test.ts`. ## Conventions @@ -59,7 +64,7 @@ The path mirrors the docs path, e.g. `_default/04-messages/01-send_message.md` m Each docs fence has exactly one marked region in a test. `yarn test-docs-sync` (no network) compares them, so later edits on either side fail until they agree again: ```ts -// #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 +// #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 // COPY: channelId="general" const message = await client .channel('messaging', channelId) @@ -68,15 +73,16 @@ const message = await client ``` - **Fence key**: - - `docs` is the page path relative to `getstream.io/content/docs/chat/`. + - `docs` is the page path relative to `getstream.io/content/docs/` (`chat/...` or `platform/...`). - `heading` is the text of the nearest heading above the fence. - `tab` is the fence `label` (or `unlabelled`). - `index` is 1-based among fences with the same heading text and tab (default 1). - **COPY**: `name="literal"` pairs, applied to the region before comparing (identifier `name` becomes the docs literal). Don't use COPY variables as object keys or shorthand properties. - **Comparison**: both sides are formatted with the same prettier options, so quote style and line wrapping don't matter, but any code or comment change does. -- **Coverage** (checked first): every JS fence (`js`/`javascript`/`ts`/`typescript`) on the pages of the chat JavaScript sidebar (`content/docs/_sidebars/[chat][javascript].json`), whether or not the page has tests yet, must have a region (inside `it.skip` if it can't run). `Node.js` fences and comment-only fences (e.g. the "server-side only" placeholders) are ignored. +- **Coverage** (checked first): every JS fence (`js`/`javascript`/`ts`/`typescript`) on the pages of the tested sidebars (`SIDEBARS` in `sync/parse.ts`: `[chat][javascript].json`, `[platform][default].json`), whether or not the page has tests yet, must have a region (inside `it.skip` if it can't run). `Node.js` fences and comment-only fences (e.g. the "server-side only" placeholders) are ignored. - **Labels**: every JS fence on those pages must be labelled `JavaScript` or `Node.js`; unlabelled or otherwise labelled JS fences fail. - **Excluded pages**: `EXCLUDED_PAGES` in `sync/parse.ts` lists sidebar pages that are deliberately not validated, with a reason (e.g. the v9 upgrade guide's TSX type examples). Both checks skip them. -- **`DOCS_SYNC_WRITE= yarn test-docs-sync`** writes the regions of that page into the docs fences (docs prettier style). Use it to apply a fix to the docs. `DOCS_SYNC_WRITE=1` writes all pages. -- **`DOCS_CHAT_DIR`** points at another docs checkout (default: `../getstream.io/content/docs/chat` next to this repo). The check is skipped if the directory doesn't exist. +- **Excluded sections**: `EXCLUDED_SECTIONS` in `sync/parse.ts` lists headings per page, with a reason. Every fence nested under one of them (at any heading level) is skipped by both checks. Used for the Feeds fences on platform pages (e.g. `Feeds Push Preferences`, whose `JavaScript`, `React` and `React Native` tabs use `@stream-io/feeds-client`). +- **`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. - Use the same variable names as the docs (`client`, `channel`, ...). diff --git a/test/docs-snippets/docs-snippets-agent-prompt.md b/test/docs-snippets/docs-snippets-agent-prompt.md index 469cdb800a..a56e5263cb 100644 --- a/test/docs-snippets/docs-snippets-agent-prompt.md +++ b/test/docs-snippets/docs-snippets-agent-prompt.md @@ -1,10 +1,10 @@ # Agent prompt: test the docs snippets of one page -Replace `{{PAGE}}` with a path from `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`, e.g. `chat/_default/04-messages/01-send_message.md`. +Replace `{{PAGE}}` with a path from `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`, e.g. `chat/_default/04-messages/01-send_message.md` or `platform/02-identity-and-access/04-multi-tenancy.md`. --- -You are verifying the client-side JavaScript code snippets of one Stream Chat docs page by writing typed tests that run them against a real Stream app with a user token. Only fences labelled `JavaScript` are in scope. Docs fences must always have a label: if you find an unlabelled `js` fence, label it (`JavaScript` for client code, `Node.js` for `@stream-io/node-sdk` code) and report it. `Node.js` fences are out of scope: don't test, add or change them. +You are verifying the client-side JavaScript code snippets of one Stream Chat docs page by writing typed tests that run them against a real Stream app with a user token. Only fences labelled `JavaScript` are in scope. Docs fences must always have a label: if you find an unlabelled `js` fence, label it (`JavaScript` for client code, `Node.js` for server-side code, with or without `@stream-io/node-sdk`) and report it. `Node.js` fences are out of scope: don't test, add or change them. **Page:** `/Users/zitaszupera/Stream/getstream.io/content/docs/{{PAGE}}` @@ -17,9 +17,10 @@ You are verifying the client-side JavaScript code snippets of one Stream Chat do - the docs page itself - this page's status row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md` - `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/custom-types.d.ts`, to add custom data fields the snippets use (augment the matching `Custom*Data` interface; never remove or change existing fields) - - type definitions in `stream-chat-js/src/` (types only, see below) + - `stream-chat-js/src/`, to fix SDK issues (see below) - Do not edit the helpers, the configs, or other pages. If a helper is missing something, write a local helper in your test file and propose the shared helper in your report. -- **`src/` may only be edited to fix TypeScript types** (e.g. a wrong or missing field in `src/types.ts`, a too-narrow parameter type), when a correct docs snippet fails to typecheck. No runtime / behavior changes. After such an edit, `yarn types` and `yarn test` must still pass. List every change in the report. +- **Fix stream-chat-js issues in `src/`** when a correct docs snippet hits one: a wrong or missing type (e.g. a field in `src/types.ts`, a too-narrow parameter type) or an SDK bug. Make the smallest fix; for a runtime fix, add or update a unit test under `test/unit/`. After the edit, `yarn types`, `yarn lint` and `yarn test` must still pass. List every change in the report. +- **API (backend) behavior issues** are not blockers: if the API behaves unexpectedly (inconsistent, misleading error, missing field) but the snippet can still be tested, test what works, add the issue to the "Possible API issues" list in the TODO (page, behavior, exact error), and carry on. - Change existing docs snippets as little as possible, and only when they are actually wrong. - Never put the API key or secret in any file. They are loaded from `test/docs-snippets/.env`. - In getstream.io, never run `npm run ci` or `npm run lint` for the whole repo. They rewrite thousands of files, including image assets. Use only the per-file commands listed below. @@ -27,7 +28,7 @@ You are verifying the client-side JavaScript code snippets of one Stream Chat do ## Read first 1. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/learnings.md`: issues that earlier agents ran into. Follow it. -2. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/README.md`: layout, helpers, isolation, cleanup and snippet-marker conventions. Look at `pages/04-messages/01-send_message.test.ts` as an example. +2. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/README.md`: layout, helpers, isolation, cleanup and snippet-marker conventions. Look at `pages/chat/04-messages/01-send_message.test.ts` as an example. 3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`. Also read the page's entries in the TODO's **Blockers detail**, **Setup notes** and **Known docs bugs** sections. 4. When the page doesn't make clear whether an endpoint is client-side or server-side only, check the OpenAPI specs: https://github.com/GetStream/protocol/tree/main/openapi/v2. An endpoint missing from the client spec is server-only. Also check the SDK source in `stream-chat-js/src/` for the method's signature and types. @@ -37,7 +38,8 @@ You are verifying the client-side JavaScript code snippets of one Stream Chat do - **Client test** (`test/docs-snippets/pages/
/.test.ts`): run each `JavaScript` snippet with a user token, using `getClientSideClient(user)`. Use `getServerClient()` only for setup, cleanup and assertions a client can't do. It returns a `@stream-io/node-sdk` client (see the README's server client notes), never a stream-chat-js `StreamChat` with the secret. - If a snippet (or part of it) only works server-side (needs the secret, `user_id` on behalf of others, app settings, ...), it doesn't belong in a client tab: remove it from the page (see "Server-side code in JavaScript snippets") and list it in the report. Don't add a `Node.js` tab for it. - If every `JavaScript` fence of the page turns out to be server-only, don't write a test file. -- **Mirror paths.** The test paths mirror the docs path without the `chat/_default/` prefix. Pages under `chat/javascript/` go under `pages/javascript/...`. +- **Mirror paths.** The test paths mirror the docs path relative to `content/docs/`, without `_default/`: `chat/_default/04-messages/...` goes under `pages/chat/04-messages/...`, `chat/javascript/...` under `pages/chat/javascript/...`, `platform/...` under `pages/platform/...`. +- **Other products (platform pages).** Platform pages also have Feeds and Video fences. Only fences that use stream-chat-js are in scope (Chat, and Video `call_level` push preferences set through the chat client). Feeds fences (`@stream-io/feeds-client`, `useCreateFeedsClient`, `feeds_level`, `addComment`, `createDevice`/`listDevices`/`deleteDevice`) and `React` / `React Native` tabs are excluded through `EXCLUDED_SECTIONS` in `sync/parse.ts`. If you find a non-stream-chat-js fence the sync check still asks you to cover, don't test it and don't edit `sync/parse.ts`: report it with the heading to exclude. - **Test structure:** - `describe` per page. - `beforeAll` creates the shared users and channels with `uniqueId(...)` ids and registers them on a `Cleanup`. @@ -46,12 +48,12 @@ You are verifying the client-side JavaScript code snippets of one Stream Chat do - **Snippet markers.** Every `JavaScript` fence on the page needs exactly one marker in a test (`Node.js` fences are ignored). `yarn test-docs-sync` checks the format and the coverage. - Wrap the docs code like this: ```ts - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 // COPY: channelId="general", userId="john" ...docs code... // #endregion snippet ``` - - `docs` is the page path relative to `content/docs/chat/`. + - `docs` is the page path relative to `content/docs/` (same as `{{PAGE}}`). - `heading` is the exact text of the nearest heading above the fence. - `tab` is the fence label, or `unlabelled`. - `index` (default 1) counts fences with the same heading text and tab on the page. If a key is wrong, the sync check lists the page's fence keys. @@ -82,7 +84,7 @@ You are verifying the client-side JavaScript code snippets of one Stream Chat do ## Editing the docs page -- Write fixes from the test with `DOCS_SYNC_WRITE= yarn test-docs-sync`. This writes every region of that page into its fence, applying the COPY values and the docs prettier style (double quotes). Don't paste snippets by hand. +- Write fixes from the test with `DOCS_SYNC_WRITE= yarn test-docs-sync`. This writes every region of that page into its fence, applying the COPY values and the docs prettier style (double quotes). Don't paste snippets by hand. - Follow `/Users/zitaszupera/Stream/getstream.io/content/docs/AGENTS.md`. In particular, no em-dashes in prose or comments. - Then run, from `/Users/zitaszupera/Stream/getstream.io/content/docs`: - `npx prettier --write ` @@ -96,7 +98,7 @@ If a docs snippet is wrong (wrong method name or signature, a type error against 2. Write it to the docs page with `DOCS_SYNC_WRITE= yarn test-docs-sync`, then check `git diff` in getstream.io: only the intended fence should change. 3. Record it for the report as before/after with the reason and the evidence (error message or type error). -If the snippet is correct but stream-chat-js types reject it, fix the type in `src/` (types only) instead of the snippet. +If the snippet is correct but stream-chat-js rejects it (types or runtime), fix `src/` instead of the snippet. If you're not sure whether the snippet or your setup is wrong, don't edit the docs. Mark it as an open question in the report instead. @@ -109,10 +111,10 @@ yarn test-docs # green, with no `LEAK` or `DRIFT` error yarn types-docs # no type errors (strict TS, no `any` escapes, no `!` non-null assertions) yarn eslint test/docs-snippets # zero warnings (same rules as src/: `import type`, sorted import members, no `!`, no unused vars) npx prettier --check test/docs-snippets -yarn test-docs-sync # every JavaScript fence in the JS sidebar covered, every JS fence labelled JavaScript/Node.js, every region identical to its docs fence +yarn test-docs-sync # every JavaScript fence on the tested sidebars (chat JS, platform) covered, every JS fence labelled JavaScript / Node.js, every region identical to its docs fence ``` -Don't use `@ts-ignore`/`@ts-expect-error` to silence a docs snippet. A type error in a snippet is either a docs bug (fix it as above) or an SDK typing bug (fix the type in `src/`, types only, and report it). +Don't use `@ts-ignore`/`@ts-expect-error` to silence a docs snippet. A type error in a snippet is either a docs bug (fix it as above) or an SDK typing bug (fix the type in `src/` and report it). Finally, update the page's row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`: `[x]` when done, `[~]` when there are open questions. @@ -122,6 +124,6 @@ Finally, update the page's row in `/Users/zitaszupera/Stream/stream-chat-js/test 2. **Docs snippet corrections**: for each one, a before/after diff, the reason, and the evidence. These need human review. 3. **Skipped / untestable snippets**: for each one, the snippet and why. 4. **Removed server-side code**: each removed fence or part of a fence, with the client-side error (or the reason) that shows it is server-only. -5. **stream-chat-js type fixes**: every `src/` change as a diff, with the snippet that needed it. -6. **Possible SDK issues**: runtime problems that look like stream-chat-js bugs rather than docs bugs (not fixed). -7. **Proposed learnings** for `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/learnings.md`: a numbered list of short, reusable lessons that would have saved you time (e.g. "`channel.muteStatus()` needs a watched channel"). Skip page-specific trivia. The user will approve or reject each one. +5. **stream-chat-js fixes**: every `src/` change as a diff, with the snippet that needed it. +6. **Open SDK issues and API issues**: stream-chat-js problems you couldn't fix, and the API behavior issues you added to the TODO. +7. **Proposed learnings** for `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/learnings.md`: a numbered list of short, reusable lessons that would have saved you time (e.g. "`channel.muteStatus()` needs a watched channel"). Skip page-specific trivia. They are reviewed before being added. diff --git a/test/docs-snippets/docs-snippets-todo.md b/test/docs-snippets/docs-snippets-todo.md index 4c9105c1ee..741aeacf09 100644 --- a/test/docs-snippets/docs-snippets-todo.md +++ b/test/docs-snippets/docs-snippets-todo.md @@ -2,9 +2,9 @@ Pages under `getstream.io/content/docs/` that contain JavaScript snippets. Give each page to an agent with `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-agent-prompt.md`. **Run pages one at a time**: tests may change app settings (they must restore them). -- Only client-side snippets are in scope: fences labelled `JavaScript` of pages in the JavaScript sidebar. Unlabelled `js` fences get a label (`JavaScript` or `Node.js`). `Node.js` fences and pages only in the Node sidebar are out of scope. +- Only client-side snippets are in scope: fences labelled `JavaScript` of pages in the chat JavaScript sidebar and the platform sidebar. On platform pages, only fences that use stream-chat-js count: Feeds sections are excluded (`EXCLUDED_SECTIONS` in `sync/parse.ts`). Unlabelled `js` fences get a label (`JavaScript` or `Node.js`). `Node.js` fences and pages only in the Node sidebar are out of scope. - **JS / unlabelled**: number of `js` fences labelled `JavaScript` / with no label (before the docs were changed). -- **Client test**: `yes` when the page gets a test under `test/docs-snippets/pages/...`; `-` when nothing on it can be tested client-side. +- **Client test**: `yes` when the page gets a test under `test/docs-snippets/pages/...` (`chat/_default/X` maps to `pages/chat/X`, `platform/X` to `pages/platform/X`); `-` when nothing on it can be tested client-side. **Blockers** (details below; empty = fully runnable, maybe with setup notes): @@ -16,7 +16,7 @@ Status: `[ ]` todo, `[x]` done, `[~]` done with open questions. **v10 (2026-10-13):** every page below was ported to stream-chat-js v10 (tests + JavaScript fences on the getstream.io branch `stream-chat-js-v10-docs-snippets`); no page is blocked. Behaviour worth knowing: moderation v2 records only a user's first flag on a message (later flags don't update reason/custom). -App facts (2026-10-05): push v3 (no providers), permissions v2, multi-tenancy off (can be toggled), campaigns on, no webhook/SQS/SNS. Enabled since (not probed yet): message history (`queryMessageHistory`). Probed and **working**: location sharing (enabled by support 2026-10-06), private messaging (restricted visibility, 2026-10-06), pending messages, delivery receipts, translation, review queue, guest users, dynamic partitioning config, multi-tenancy toggle. +App facts (2026-10-05): push v3 (providers: type `firebase` named `firebase`, type `firebase` named `apn`; the docs' Firebase example config is rejected by `upsertPushProvider`), permissions v2, multi-tenancy off (can be toggled), campaigns on, no webhook/SQS/SNS. Enabled since (not probed yet): message history (`queryMessageHistory`). Probed and **working**: location sharing (enabled by support 2026-10-06), private messaging (restricted visibility, 2026-10-06), pending messages, delivery receipts, translation, review queue, guest users, dynamic partitioning config, multi-tenancy toggle. ## chat/\_default/02-init_and_users @@ -91,12 +91,45 @@ App facts (2026-10-05): push v3 (no providers), permissions v2, multi-tenancy of | [x] | `chat/javascript/01-quick_start/01-plain_js_introduction.md` | 4 | 0 | yes | | | — | ~~`chat/javascript/11-debugging_and_cli/10-upgrading_to_v9.md`~~ | 0 | 8 | - | Excluded | +## platform/02-identity-and-access + +**JS** counts only stream-chat-js fences; Feeds `JavaScript` fences in excluded sections are listed in the Blockers cell. + +| Status | Page | JS | Unlabelled | Client test | Blockers | +| ------ | ----------------------------------------------------- | --- | ---------- | ----------- | ------------------------------ | +| [x] | `platform/02-identity-and-access/02-users.md` | 0 | 0 | - | Excluded sections (2 Feeds JS) | +| [x] | `platform/02-identity-and-access/04-multi-tenancy.md` | 2 | 0 | yes | | + +## platform/03-events-and-notifications + +| Status | Page | JS | Unlabelled | Client test | Blockers | +| ------ | --------------------------------------------------------------- | --- | ---------- | ----------- | ------------------------------ | +| [x] | `platform/03-events-and-notifications/01-webhooks.md` | 1 | 1 | yes | | +| [x] | `platform/03-events-and-notifications/02-push-notifications.md` | 0 | 0 | - | Excluded sections (2 Feeds JS) | +| [x] | `platform/03-events-and-notifications/04-push-devices.md` | 3 | 0 | yes | ◐ push provider config | +| [x] | `platform/03-events-and-notifications/05-push-preferences.md` | 9 | 0 | yes | Excluded sections (9 Feeds JS) | + +## platform/04-using-the-api + +| Status | Page | JS | Unlabelled | Client test | Blockers | +| ------ | ---------------------------------------------------- | --- | ---------- | ----------- | -------- | +| [x] | `platform/04-using-the-api/06-connection-pooling.md` | 0 | 1 | - | | + ## Blockers detail ### `chat/javascript/11-debugging_and_cli/10-upgrading_to_v9.md` (Excluded) - Not validated; leave the page as is (React/TSX type examples, no runnable JS). Listed in `EXCLUDED_PAGES` (`sync/parse.ts`), so the sync coverage and label checks skip it. +### `platform/03-events-and-notifications/04-push-devices.md` (◐ push provider config) + +- The app has one push provider (added 2026-10-14): type `firebase`, named `firebase` (multi-bundle). Devices need both the type and the name: `addDevice(id, "firebase")` without the name fails with code 4 'push provider "" with type "firebase" not found'. The listing and removing tests register a device in setup with `addDevice(id, "firebase", undefined, name)`, where `name` comes from `STREAM_PUSH_PROVIDER_NAME` in `.env` (the name of a Firebase provider; the tests are skipped when it's empty). +- "Registering from the chat client SDKs" stays `it.skip('BLOCKED: ...')`. The fence shows two alternatives: `addDevice(id, "apn")` for single-provider apps and `addDevice(id, "apn", undefined, push_provider_name)` for multi-bundle apps. The test app is multi-bundle (named providers), and as the page documents, multi-bundle configurations require `push_provider_name`: the first call fails with code 4 'push provider "" with type "apn" not found'. One app can't run both lines. The app's provider named `apn` has type `firebase`, so the second line would also need an `apn`-type provider. + +### Excluded sections (Feeds) + +- `platform/02-identity-and-access/02-users.md` ("Guest users", "Anonymous users"), `platform/03-events-and-notifications/02-push-notifications.md` (all `JavaScript` / `React` / `React Native` fences), the Feeds fences of `04-push-devices.md` and the "Feeds Push Preferences" section of `05-push-preferences.md` use `@stream-io/feeds-client` (or notifee), not stream-chat-js. They're listed in `EXCLUDED_SECTIONS` (`sync/parse.ts`) and not validated. + ## Setup notes (not blockers) - **02-init_and_users/01-client_tokens_and_authentication**: `devToken` needs `disable_auth_checks: true`: toggle it for the test and restore it. @@ -111,6 +144,11 @@ App facts (2026-10-05): push v3 (no providers), permissions v2, multi-tenancy of - **05-features/09-translation**: `translateMessage` works (probe). Enable auto-translation per channel server-side (`update({ data: { auto_translation_enabled: true } })`); no app setting to change. - **05-features/10-advanced/11-slow_mode_and_throttling**: Slow mode cooldown is 30s in the snippet: use a short cooldown in assertions. - **12-best_practices/02-moderation**: Review queue works client-side for a user with role `admin` (probe OK). +- **platform/02-identity-and-access/04-multi-tenancy**: Toggle multi-tenancy like `chat/_default/02-init_and_users/04-user_groups` (`serverClient.updateApp({ multi_tenant_enabled })` + `waitForAppSetting`, restore it). `team: "red"`: use a `uniqueId` team in a COPY variable. The "all teams" / "no team" queries need a user with `read-channel-any-team` (built-in `global_read_only` or `global_admin` role): for other users the API returns an access error (code 70) when inaccessible channels match, as the page documents. +- **platform/03-events-and-notifications/01-webhooks**: Don't verify that webhooks are delivered: asserting on the response or a getter is enough. The unlabelled fence ("Where each argument comes from") is node-sdk code: label it `Node.js`. +- **platform/03-events-and-notifications/04-push-devices**: Use the app's `firebase` provider (type and name `firebase`), see Blockers detail. Don't verify that a push arrives: `getDevices()` is enough. +- **platform/03-events-and-notifications/05-push-preferences**: Don't verify that pushes are delivered or suppressed. Setting preferences needs no push provider. Read them back client-side: user-level from a fresh `connectUser` (`me.push_preferences`), channel-member level from `queryChannels`/`watch` (`channel.push_preferences`). Channel preferences need an existing channel (code 16 "channel with CID ... does not exist"). +- **platform/04-using-the-api/06-connection-pooling**: The unlabelled fence ("SDK-specific notes") is node-sdk code (`StreamClient`, undici): label it `Node.js`; no test. - **All pages that create/update channel types**: Wait 30s (`waitForChannelTypePropagation()`) after `createChannelType` / `updateChannelType` before using the type. ## Known docs bugs @@ -150,6 +188,15 @@ Found while migrating the snippets to v10 and patched by hand in `src/gen/models - [ ] **Object-valued custom fields in filters** (`generator/templates/templates/typescript/models.tpl`, `CustomHelper`): a typed `custom.` key is built for every custom data field, including object-valued ones (e.g. `CustomChannelData.source_detail: { user_id: number }`). Its value doesn't fit the `custom.${string}` index signature (`string | boolean | number | Date`), so every filter type built from the custom data stops being assignable, including the SDK's own search sources (`ChannelSearchSource`, `MessageSearchSource`). Fix: only emit typed keys for primitive and `Date` fields (`NonNullable extends string | boolean | number | Date`); object fields fall back to the index signature, which already accepts nested paths like `custom.source_detail.user_id`. Covered by `test/types/custom-filters.ts`. - [ ] **Request dates typed as response dates** (OpenAPI spec): `created_at_after`, `created_at_after_or_equal`, `created_at_before` and `created_at_before_or_equal` on `QueryMembersPayload`, `QueryBannedUsersPayload` and `QueryFutureChannelBansPayload` are typed `TimestampNS` (the spec types them as numbers, the server-sent direction). They are request fields, sent in the JSON `payload` query param, and the server parses them as RFC 3339: a number fails with `Time.UnmarshalJSON: input is not a JSON string` (`03-channels/04-query_members`, "Paginate by created_at"). Fix the spec so they are request (string) dates, which the generator types as `Date`. Also check `ChannelStopWatchingRequest` (`created_at`, `received_at` typed `TimestampNS`), not investigated. +- [ ] **Empty `team` channel filter** (OpenAPI spec + `models.tpl`, `Filters`): with multi-tenancy, `team: {}` queries channels across all teams (`platform/02-identity-and-access/04-multi-tenancy`, "Querying chat channels"), but every filter key only accepted an operator object with exactly one operator. Patched by hand: an `allowEmpty: true` flag on the `team` entries of `QueryChannelsRequest` and `SearchPayload` (both are `ChannelFilters`, and `MessageSearchSource` assigns one to the other), which `Filters` turns into an extra `Record` member. Fix: have the spec mark the key and the template emit the flag and the `Filters` branch. Covered by `test/types/team-filter.ts`. + +## Possible API issues + +Found while testing. Not blockers. + +- **Multi-tenancy, code 70 hint** (`platform/02-identity-and-access/04-multi-tenancy`, backend): the access error for cross-team queries (documented: "an access error will be returned") suggests "Did you forget to include {members: $in: [...]}?" even when the filter already has it. Misleading hint. +- **Multi-tenancy, cross-team membership** (backend): server-side `getOrCreate({ data: { team, members } })` accepts members from other teams, but `update({ add_members })` rejects them (code 5 "user from teams [...] cannot be a member of a channel with team ..."). Inconsistent. +- **Push preferences response** (backend): the `setPushPreferences` response for a channel-only update has `user_preferences: { : null }`. ## Notes diff --git a/test/docs-snippets/learnings.md b/test/docs-snippets/learnings.md index 054ee4a0c5..91590c330e 100644 --- a/test/docs-snippets/learnings.md +++ b/test/docs-snippets/learnings.md @@ -6,22 +6,24 @@ Entries are added only after the user approves them (agents propose them in thei ## Setup and tooling - To probe API behavior quickly, write a throwaway vitest file under `test/docs-snippets/pages/` that logs each call's result or error. It still runs the leak check, so register what it creates, and delete the file before finishing. -- Fences containing `import ... from "stream-chat"` can be regions: `stream-chat` is aliased to `src` (vitest `resolve.alias` + tsconfig `paths`). Put the region at module level, below the other imports, with the values it uses (e.g. `const apiKey`) declared above it, since a `const` after it would be in the TDZ. Example: `pages/02-init_and_users/02-init_and_users.test.ts`. +- Fences containing `import ... from "stream-chat"` can be regions: `stream-chat` is aliased to `src` (vitest `resolve.alias` + tsconfig `paths`). Put the region at module level, below the other imports, with the values it uses (e.g. `const apiKey`) declared above it, since a `const` after it would be in the TDZ. Example: `pages/chat/02-init_and_users/02-init_and_users.test.ts`. - `StreamChat.getInstance` is a singleton for the whole test file. To run another `getInstance` snippet with different options, reset it outside the region: `Reflect.set(StreamChat, '_instance', undefined)`. - Strict `types-docs` flags optional response fields (TS18048): read them with `?.`. node-sdk types some as required (`getApp().app` and its fields), others as optional (`getOrCreate().channel`, `getBlockList().blocklist`). - If a snippet awaits SDK methods without keeping the result (e.g. `await channel.queryMembers({...});`), `vi.spyOn(obj, 'method')` before the region, await `spy.mock.results.map((r) => r.value)` after it to assert on the responses, then `spy.mockRestore()` (a second `spyOn` on the same method returns the old spy with its call history). A call that isn't awaited at all is a docs bug (see "Docs conventions"): add `await` instead. - If the region creates its own instance (e.g. `const channel = client.channel(...)`), there's nothing to spy on beforehand: spy on the prototype (`vi.spyOn(Channel.prototype, 'update')`). - Destructuring awaited results with a default (`const [x = []] = await Promise.all(spy.mock.results.map((r) => r.value))`) loses the element type (TS7006); use `.flat()` on the results instead. Plain `await Promise.all(spy.mock.results.map(...))` is `any[]`: annotate the destructuring with the response type (`const [a, b]: QueryReactionsAPIResponse[] = ...`). - `retry(fn)` (`helpers/wait.ts`) takes `() => Promise`. A callback with only synchronous `expect`s fails `require-await` if marked `async`: write `() => { expect(...); return Promise.resolve(); }`. -- To test a snippet that starts with `client.connectUser(user, token)`, don't use `getClientSideClient`: build an unconnected `new StreamChat(process.env.STREAM_API_KEY as string, { allowServerSideConnect: true })`, upsert the user first, and seed state server-side before connecting (e.g. a pre-muted channel) so the connect response has something to assert on. Example: `pages/03-channels/07-channel_management/10-muting.test.ts`. +- To test a snippet that starts with `client.connectUser(user, token)`, don't use `getClientSideClient`: build an unconnected `new StreamChat(process.env.STREAM_API_KEY as string, { allowServerSideConnect: true })`, upsert the user first, and seed state server-side before connecting (e.g. a pre-muted channel) so the connect response has something to assert on. Example: `pages/chat/03-channels/07-channel_management/10-muting.test.ts`. - `vi.mock` in a test file doesn't affect modules `setup.ts` already imported (src and its deps). To mock one, call `vi.resetModules()` and re-import src dynamically (`await import('../../../../src')`). -- Upload snippets with browser `File`s fail under Node (the SDK's `form-data` path only takes streams/Buffers and detects `File` only when `window` exists). Run them with `vi.mock('form-data', () => ({ default: globalThis.FormData }))`, `vi.stubGlobal('window', { File, Blob })` in `beforeEach` (`vi.unstubAllGlobals()` in `afterEach`) and a client built from a fresh src import (see above). Example: `pages/04-messages/02-file_uploads.test.ts`. +- Upload snippets with browser `File`s fail under Node (the SDK's `form-data` path only takes streams/Buffers and detects `File` only when `window` exists). Run them with `vi.mock('form-data', () => ({ default: globalThis.FormData }))`, `vi.stubGlobal('window', { File, Blob })` in `beforeEach` (`vi.unstubAllGlobals()` in `afterEach`) and a client built from a fresh src import (see above). Example: `pages/chat/04-messages/02-file_uploads.test.ts`. - A marker's `heading` is the nearest heading of any level above the fence, including `####` subheadings. When `test-docs-sync` reports "no such fence", it lists the correct key. - To check state between two calls inside one region, wrap the first call: capture `const original = obj.m.bind(obj)` before the region, then `vi.spyOn(obj, 'm').mockImplementation(async (...args) => { const r = await original(...args); /* expect */ return r; })`. - Fences that log from event handlers (`console.log("…", event.x)`): `vi.spyOn(console, 'log').mockImplementation(() => undefined)` before the region, trigger the events after it, `retry` until the expected call appears, and restore the spy in a `finally`. - Placeholders a fragment uses (`filter`, `sort`, `next`, ...) are defined outside the region: type them with the SDK types (`ReminderSort`, `ReminderFilters`, ...) so literals don't widen, and compute cursors from real queries. - When a region uses `client` but must run as another user, add `const client = otherClient;` in the `it` before the region. Setup in that block that needs the outer `client` must move into a describe-level helper (using `client` before the shadowing `const` is a TDZ error, TS2448). - When you remove a fence from a page, other fences' `index` can shift: remove the fences in the docs first, then run `DOCS_SYNC_WRITE`, or the write lands in the fence at the old index. +- COPY substitution replaces the identifier everywhere in the region, including comments: a test variable `red` (COPY `red="red"`) turned the docs comment "for team red" into `for team "red"`. Name COPY variables so they can't match words in the docs text (`redTeam`, `channelId`). +- A heading with double quotes goes into the marker escaped: `heading="1: Creating a \"Do Not Disturb\" Mode"` (marker attributes are parsed as JSON strings). ## Server client (`@stream-io/node-sdk`) @@ -43,7 +45,7 @@ Entries are added only after the user approves them (agents propose them in thei - Optional SDK fields (`event.user`, `channel.data`, `client.user`, `message.poll`, ...) make fences reading `x.y.z` fail `types-docs` with TS18048. Fix the docs with `?.`, not the SDK type. If the value is used inside a callback, read it into a local `const` first: narrowing doesn't carry into closures. - `team_id` and multi-tenancy: with multi-tenancy off, any `team_id` is rejected ("team_id is not supported when multi-tenancy is not enabled", code 4); with it on, client-side calls must pass it ("team_id is required when multi-tenancy is enabled"). Snippets with and without `team_id` need separate phases (e.g. a nested `describe` that turns multi-tenancy on and restores it). - Toggling `multi_tenant_enabled` is eventually consistent across API nodes (a few seconds; one node returned the old value after 5 matching reads). Wait with `waitForAppSetting(serverClient, 'multi_tenant_enabled', value)` (`helpers/server.ts`, ~20 consecutive agreeing reads), both after enabling and after restoring. -- `disable_auth_checks` (needed for `devToken` snippets) can be toggled now that the app is in development mode; save and restore it. The WS edge accepts dev tokens later than `getApp()` reports the new value (`connectUser` fails with 401 code 5 "development tokens are not allowed for this application"): after enabling, retry a throwaway dev-token `connectUser` outside the region until it succeeds (see `waitForDevTokensAccepted` in `pages/02-init_and_users/01-client_tokens_and_authentication.test.ts`). +- `disable_auth_checks` (needed for `devToken` snippets) can be toggled now that the app is in development mode; save and restore it. The WS edge accepts dev tokens later than `getApp()` reports the new value (`connectUser` fails with 401 code 5 "development tokens are not allowed for this application"): after enabling, retry a throwaway dev-token `connectUser` outside the region until it succeeds (see `waitForDevTokensAccepted` in `pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts`). - Predefined filters: `createPredefinedFilter({ name: uniqueId(...), operation: 'QueryChannels', filter: {...} })` is usable immediately, client-side and server-side. Register `cleanup.add(() => serverClient.chat.deletePredefinedFilter({ name }))` and put the docs' filter name in a COPY variable. - `$and`/`$nor` (`ArrayOneOrMore`) and `$or` (`ArrayTwoOrMore`) are tuple types. A filter stored in a `const` is inferred as a plain array and isn't assignable to `ChannelFilters`. If the region is only the filter fragment, run it after the region via the raw endpoint (`client.post(`${client.baseURL}/channels`, { filter_conditions, ... })`) instead of casting. - `client.channel(type, id, data)` only builds a local object. A snippet that goes straight to `updatePartial`/`update`/... on a new id fails with 404 code 16 "Can't find channel": that's a docs bug, fix it by adding `await channel.create()` to the snippet. @@ -60,7 +62,7 @@ Entries are added only after the user approves them (agents propose them in thei - `channel.truncate()` works client-side for the channel creator (`messaging`). A whole-channel truncate sets the channel's `truncated_at`; a later truncate with an earlier `truncated_at` fails ("must be more recent than previous one"), so use a fresh channel per tab group that passes `truncated_at`. - Invites: a `messaging` channel creator can `inviteMembers` client-side, and the invited user can `acceptInvite()` / `rejectInvite()` on `client.channel(type, id)` without `watch()`. Check a member's invite state with server-side `serverClient.chat.channel(type, id).queryMembers({ payload: { filter_conditions: { user_id } } })`: invited = `invited: true` + `status: 'pending'`, accepted = `invite_accepted_at` + `status: 'member'`, rejected = `invite_rejected_at` + `status: 'rejected'`. To prove `queryChannels({ invite })` filters, give the user one accepted, one rejected and one pending channel in `beforeAll` and assert each query returns only its own. - Batch updates (`channelBatchUpdater()`, `updateChannelsBatch`), `getTask` and `undeleteMessage` are server-only (403 code 17 "this endpoint can only be called server side"): remove them from `JavaScript` tabs. -- In the test app, `messaging` doesn't grant `channel_member` `create-attachment`, `create-mention`, `create-system-message`, `create-restricted-visibility-message` or `notify-here`/`notify-channel`/`notify-role`/`notify-group`: client-side attachments, `type: "system"` messages or `@here`/`@channel`/role/group mentions fail with 403 code 17 ("not allowed to perform action CreateAttachment/NotifyHere/CreateRestrictedVisibilityMessage...", "creating system messages not allowed"). Add them to `messaging` with `grantMessagingMembers(serverClient, cleanup, [...])` (`helpers/grants.ts`, registers the restore) followed by `waitForChannelTypePropagation()`; don't create a separate channel type. Example: `pages/04-messages/01-send_message.test.ts`. Uploading itself (`channel.sendImage`/`sendFile`, `client.uploadImage`, `deleteFile`/`deleteImage`) needs no extra grant: only sending a message with attachments needs `create-attachment`. +- In the test app, `messaging` doesn't grant `channel_member` `create-attachment`, `create-mention`, `create-system-message`, `create-restricted-visibility-message` or `notify-here`/`notify-channel`/`notify-role`/`notify-group`: client-side attachments, `type: "system"` messages or `@here`/`@channel`/role/group mentions fail with 403 code 17 ("not allowed to perform action CreateAttachment/NotifyHere/CreateRestrictedVisibilityMessage...", "creating system messages not allowed"). Add them to `messaging` with `grantMessagingMembers(serverClient, cleanup, [...])` (`helpers/grants.ts`, registers the restore) followed by `waitForChannelTypePropagation()`; don't create a separate channel type. Example: `pages/chat/04-messages/01-send_message.test.ts`. Uploading itself (`channel.sendImage`/`sendFile`, `client.uploadImage`, `deleteFile`/`deleteImage`) needs no extra grant: only sending a message with attachments needs `create-attachment`. - Client-side `deleteChannels` returns a synchronous `result: { [cid]: { status } }` and no `task_id` (the `task_id` shape is server-side only). - `channel.deleteReaction()` calls `_checkInitialized`, so the channel must be watched (`sendReaction` doesn't need it). - `channel.muteStatus()` throws "hasn't been initialized yet" unless the channel is watched (fix the docs with `await channel.watch()`). It reads `client.mutedChannels`, which only updates on the `notification.channel_mutes_updated` WS event, not from the `mute()` response: poll it with `retry` after the region. @@ -80,6 +82,8 @@ Entries are added only after the user approves them (agents propose them in thei - To trigger `connection.changed` client-side: `client.wsConnection?.onlineStatusChanged(new globalThis.Event('offline'))` (`online: false` ~5s later), then `'online'` to reconnect (`online: true`). `closeConnection()`/`openConnection()` only emit `online: true`. Use `globalThis.Event`: the `Event` type from src shadows the DOM name. - Listeners a snippet registers without keeping the unsubscribe leak into later tests: use a fresh watched channel per test and reset `channel.listeners = {}` in `finally`; for `client.on`, `vi.spyOn(client, 'on')` and call `unsubscribe()` on the `mock.results` values. - `channel.keystroke()` sends `typing.start` at most once every 2s per channel, ignoring `parent_id`; `stopTyping()` resets the throttle. To assert a thread `keystroke(parentId)`, call it after `stopTyping()` or on a fresh channel, and record events on a second member's watched channel. +- Multi-tenancy access is team-scoped: a channel only accepts members from its own team (`update({ add_members })`: code 5 "user from teams [...] cannot be a member of a channel with team ..."), a user without teams only sees channels without a team, and a team user's `queryChannels({ team: {} })` / `({ team: { $eq: null } })` fails with code 70 as soon as an inaccessible channel matches (documented on the page; even for a member, even for role `admin`). Cross-team reads need a `*-any-team` grant: the built-in `global_read_only` / `global_admin` roles have `read-channel-any-team`. +- Push preferences (`setPushPreferences`) work without a push provider. Read them back client-side: user-level from a fresh `connectUser` response (`me.push_preferences`), channel-member level from `queryChannels`/`watch` (`channel.push_preferences`). Devices (`addDevice`) need a matching push provider on the app: the test app's providers are named (multi-bundle), so register with `addDevice(id, "firebase", undefined, process.env.STREAM_PUSH_PROVIDER_NAME)` (a Firebase provider's name, set in `.env`). Without the name: code 4 'push provider "" with type "firebase" not found'. ## Client-side vs server-side diff --git a/test/docs-snippets/pages/02-init_and_users/01-client_tokens_and_authentication.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts similarity index 87% rename from test/docs-snippets/pages/02-init_and_users/01-client_tokens_and_authentication.test.ts rename to test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts index f9b979027b..a8f93f5f0e 100644 --- a/test/docs-snippets/pages/02-init_and_users/01-client_tokens_and_authentication.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts @@ -1,9 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { StreamChat } from '../../../../src'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerUser, waitForAppSetting } from '../../helpers/server'; +import { StreamChat } from '../../../../../src'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser, waitForAppSetting } from '../../../helpers/server'; describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () => { const serverClient = getServerClient(); @@ -76,7 +76,7 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () await waitForAppSetting(serverClient, 'disable_auth_checks', true); await waitForDevTokensAccepted(); - // #region snippet docs="_default/02-init_and_users/01-client_tokens_and_authentication.md" heading="Developer Tokens" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/01-client_tokens_and_authentication.md" heading="Developer Tokens" tab="JavaScript" index=1 // COPY: userId="john" await client.connectUser( { diff --git a/test/docs-snippets/pages/02-init_and_users/02-init_and_users.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts similarity index 81% rename from test/docs-snippets/pages/02-init_and_users/02-init_and_users.test.ts rename to test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts index 78b3deebe5..77d9cdc107 100644 --- a/test/docs-snippets/pages/02-init_and_users/02-init_and_users.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts @@ -1,13 +1,13 @@ import { afterAll, describe, expect, it } from 'vitest'; -import { createUserToken, getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerUser } from '../../helpers/server'; +import { createUserToken, getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser } from '../../../helpers/server'; const apiKey = process.env.STREAM_API_KEY as string; // `stream-chat` resolves to src (alias in vitest.config.mts and tsconfig.json). -// #region snippet docs="_default/02-init_and_users/02-init_and_users.md" heading="" tab="JavaScript" index=1 +// #region snippet docs="chat/_default/02-init_and_users/02-init_and_users.md" heading="" tab="JavaScript" index=1 // COPY: apiKey="{{ api_key }}" import { StreamChat } from 'stream-chat'; @@ -41,7 +41,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { // The docs leave `tokenProvider` to the app: here it returns a token signed by the server. const tokenProvider = () => Promise.resolve(createUserToken(userId)); - // #region snippet docs="_default/02-init_and_users/02-init_and_users.md" heading="Connecting Users" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/02-init_and_users.md" heading="Connecting Users" tab="JavaScript" index=1 // COPY: userId="john" await chatClient.connectUser( { @@ -59,7 +59,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { expect(user.name).toBe('John Doe'); expect(user.image).toBe('https://getstream.io/random_svg/?name=John'); - // #region snippet docs="_default/02-init_and_users/02-init_and_users.md" heading="Disconnecting Users" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/02-init_and_users.md" heading="Disconnecting Users" tab="JavaScript" index=1 await chatClient.disconnectUser(); // #endregion snippet @@ -70,7 +70,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { it('connects a user with privacy settings', async () => { const userToken = createUserToken(privacyUserId); - // #region snippet docs="_default/02-init_and_users/02-init_and_users.md" heading="Privacy Settings" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/02-init_and_users.md" heading="Privacy Settings" tab="JavaScript" index=1 // COPY: privacyUserId="john", userToken="{{ chat_user_token }}" await chatClient.connectUser( { @@ -109,7 +109,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { // `getInstance` returns the existing singleton: drop it so the snippet creates a new client. Reflect.set(StreamChat, '_instance', undefined); - // #region snippet docs="_default/02-init_and_users/02-init_and_users.md" heading="XHR Fallback" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/02-init_and_users.md" heading="XHR Fallback" tab="JavaScript" index=1 // COPY: apiKey="apiKey" const chatClient = StreamChat.getInstance(apiKey); chatClient.config.set({ client: { wsConnection: { enableWSFallback: true } } }); diff --git a/test/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts similarity index 86% rename from test/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts rename to test/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts index d7647757b1..2d3527a9d3 100644 --- a/test/docs-snippets/pages/02-init_and_users/04-user_groups.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { StreamClient } from '@stream-io/node-sdk'; -import type { StreamChat } from '../../../../src'; +import type { StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { DOCS_TEST_PREFIX, uniqueId } from '../../helpers/ids'; -import { waitForAppSetting } from '../../helpers/server'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { DOCS_TEST_PREFIX, uniqueId } from '../../../helpers/ids'; +import { waitForAppSetting } from '../../../helpers/server'; const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error); @@ -87,7 +87,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { }); it('creates a user group', async () => { - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Creating a User Group" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Creating a User Group" tab="JavaScript" index=1 // COPY: groupName="Design Team", alice="alice", bob="bob", charlie="charlie" const { user_group } = await client.createUserGroup({ name: groupName, @@ -107,7 +107,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { }); it('updates a user group', async () => { - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Updating a User Group" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Updating a User Group" tab="JavaScript" index=1 // COPY: groupId="design-team", updatedGroupName="Design & Product Team" const { user_group } = await client.updateUserGroup( { id: groupId }, @@ -123,7 +123,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { }); it('adds members and changes their admin status', async () => { - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Adding Members" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Adding Members" tab="JavaScript" index=1 // COPY: groupId="design-team", dave="dave", eve="eve", frank="frank", grace="grace" // Add members as regular members const { user_group } = await client.addUserGroupMembers( @@ -176,7 +176,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { }); it('removes members', async () => { - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Removing Members" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Removing Members" tab="JavaScript" index=1 // COPY: groupId="design-team", dave="dave", eve="eve" const { user_group } = await client.removeUserGroupMembers( { id: groupId }, @@ -204,7 +204,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { }); it('retrieves a user group', async () => { - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Retrieving a User Group" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Retrieving a User Group" tab="JavaScript" index=1 // COPY: teamGroupId="design-team", teamId="my-team" const { user_group } = await client.getUserGroup( { id: teamGroupId }, @@ -222,7 +222,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { it('lists user groups', async () => { // Any id below the test group's id, so it is on the first page. const lastId = DOCS_TEST_PREFIX; - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Listing User Groups" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Listing User Groups" tab="JavaScript" index=1 // COPY: lastId="last-id", teamId="my-team" const { user_groups } = await client.listUserGroups({ limit: 20, @@ -239,7 +239,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { it('searches user groups', async () => { const namePrefix = teamGroupName; const lastId = DOCS_TEST_PREFIX; - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Searching User Groups" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Searching User Groups" tab="JavaScript" index=1 // COPY: namePrefix="design", lastId="last-id", teamId="my-team" const { user_groups } = await client.searchUserGroups({ query: namePrefix, // prefix search on group name @@ -254,7 +254,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { }); it('deletes a user group', async () => { - // #region snippet docs="_default/02-init_and_users/04-user_groups.md" heading="Deleting a User Group" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Deleting a User Group" tab="JavaScript" index=1 // COPY: teamGroupId="design-team", teamId="my-team" await client.deleteUserGroup( { id: teamGroupId }, diff --git a/test/docs-snippets/pages/02-init_and_users/05-authless_users.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts similarity index 77% rename from test/docs-snippets/pages/02-init_and_users/05-authless_users.test.ts rename to test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts index 2866cbdb1e..cbb36a2b89 100644 --- a/test/docs-snippets/pages/02-init_and_users/05-authless_users.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts @@ -1,8 +1,8 @@ import { afterAll, describe, expect, it } from 'vitest'; -import { StreamChat } from '../../../../src'; -import { getServerClient } from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +import { StreamChat } from '../../../../../src'; +import { getServerClient } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; const apiKey = process.env.STREAM_API_KEY as string; @@ -28,7 +28,7 @@ describe('_default/02-init_and_users/05-authless_users.md', () => { const guestId = uniqueId('tommaso'); try { - // #region snippet docs="_default/02-init_and_users/05-authless_users.md" heading="Guest Users" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/05-authless_users.md" heading="Guest Users" tab="JavaScript" index=1 // COPY: guestId="tommaso" await client.setGuestUser({ id: guestId }); // #endregion snippet @@ -45,7 +45,7 @@ describe('_default/02-init_and_users/05-authless_users.md', () => { it('connects an anonymous user', async () => { const client = newClient(); - // #region snippet docs="_default/02-init_and_users/05-authless_users.md" heading="Anonymous Users" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/02-init_and_users/05-authless_users.md" heading="Anonymous Users" tab="JavaScript" index=1 const connectResponse = await client.connectAnonymousUser(); console.log(connectResponse?.me); diff --git a/test/docs-snippets/pages/03-channels/01-creating_channels.test.ts b/test/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts similarity index 82% rename from test/docs-snippets/pages/03-channels/01-creating_channels.test.ts rename to test/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts index 11f9fbcde0..b93228b023 100644 --- a/test/docs-snippets/pages/03-channels/01-creating_channels.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../src'; +import type { StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; describe('_default/03-channels/01-creating_channels.md', () => { const serverClient = getServerClient(); @@ -31,7 +31,7 @@ describe('_default/03-channels/01-creating_channels.md', () => { const travel = uniqueId('travel'); cleanup.channels.push(`messaging:${travel}`); - // #region snippet docs="_default/03-channels/01-creating_channels.md" heading="Creating a Channel Using a Channel ID" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/01-creating_channels.md" heading="Creating a Channel Using a Channel ID" tab="JavaScript" index=1 // COPY: travel="travel" const channel = client.channel('messaging', travel, { custom: { name: 'Awesome channel about traveling' }, @@ -49,7 +49,7 @@ describe('_default/03-channels/01-creating_channels.md', () => { }); it('creates a distinct channel', async () => { - // #region snippet docs="_default/03-channels/01-creating_channels.md" heading="Distinct Channels" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/01-creating_channels.md" heading="Distinct Channels" tab="JavaScript" index=1 // COPY: thierry="thierry", tommaso="tommaso" const channel = client.channel('messaging', { members: [{ user_id: thierry }, { user_id: tommaso }], @@ -73,7 +73,7 @@ describe('_default/03-channels/01-creating_channels.md', () => { const travelChannel = uniqueId('travel-channel'); cleanup.channels.push(`messaging:${travelChannel}`); - // #region snippet docs="_default/03-channels/01-creating_channels.md" heading="Watching Channels" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/01-creating_channels.md" heading="Watching Channels" tab="JavaScript" index=1 // COPY: travelChannel="travel-channel" const channel = client.channel('messaging', travelChannel); diff --git a/test/docs-snippets/pages/03-channels/02-query_channels.test.ts b/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts similarity index 82% rename from test/docs-snippets/pages/03-channels/02-query_channels.test.ts rename to test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts index a870677d12..8a7056353c 100644 --- a/test/docs-snippets/pages/03-channels/02-query_channels.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../src'; +import type { StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerChannel } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; const DOCS = '_default/03-channels/02-query_channels.md'; const CHANNEL_COUNT = 12; @@ -52,7 +52,7 @@ describe(DOCS, () => { }); it('queries channels', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="" tab="JavaScript" index=1 // COPY: thierry="thierry" const channels = await chatClient.queryChannelsAndHydrate({ filter_conditions: { type: 'messaging', members: { $in: [thierry] } }, @@ -66,7 +66,7 @@ describe(DOCS, () => { }); it('filters messaging channels by member', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Messaging and Team Channels" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Messaging and Team Channels" tab="JavaScript" index=1 // COPY: thierry="thierry" const filter = { members: { $in: [thierry] }, type: 'messaging' }; // #endregion snippet @@ -81,7 +81,7 @@ describe(DOCS, () => { it('paginates with limit and offset', async () => { const authClient = chatClient; - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Pagination" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Pagination" tab="JavaScript" index=1 // COPY: thierry="thierry" // Get channels 11-30 const channels = await authClient.queryChannelsAndHydrate({ @@ -100,7 +100,7 @@ describe(DOCS, () => { cleanup.channels.push(`messaging:${channelId}`); const channel = chatClient.channel('messaging', channelId); - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Channel Creation and Watching" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Channel Creation and Watching" tab="JavaScript" index=1 await channel.create(); await channel.query(); await channel.watch(); @@ -116,7 +116,7 @@ describe(DOCS, () => { it('filters by cid, by type and members, or by type alone', async () => { const channelCID = `messaging:${channelIds[0]}`; - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Filter Best Practices" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Filter Best Practices" tab="JavaScript" index=1 // Most performant: Filter by CID const cidFilter = { cid: channelCID }; @@ -144,7 +144,7 @@ describe(DOCS, () => { }); it('sorts by last_message_at', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Sort Best Practices" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Sort Best Practices" tab="JavaScript" index=1 const sort = [{ field: 'last_message_at', direction: -1 }]; // #endregion snippet @@ -157,7 +157,7 @@ describe(DOCS, () => { }); it('builds broad and nested filters', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Bad Pattern: Overly Broad or Complex Filters" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Bad Pattern: Overly Broad or Complex Filters" tab="JavaScript" index=1 // ❌ BAD: Type-only filter (too broad) const broadFilter = { type: 'messaging' }; @@ -202,7 +202,7 @@ describe(DOCS, () => { }); it('queries with a predefined filter in production', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Using Predefined Filters in Production" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Using Predefined Filters in Production" tab="JavaScript" index=1 // COPY: userMessagingChannels="user_messaging_channels" // Production-ready: Use Predefined Filter const channels = await chatClient.queryChannelsAndHydrate({ @@ -219,7 +219,7 @@ describe(DOCS, () => { it('queries with a predefined filter', async () => { const user123 = thierry; - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Using Predefined Filters" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Using Predefined Filters" tab="JavaScript" index=1 // COPY: userMessagingChannels="user_messaging_channels", user123="user123" const channels = await chatClient.queryChannelsAndHydrate({ predefined_filter: userMessagingChannels, @@ -234,7 +234,7 @@ describe(DOCS, () => { }); it('uses a simple, selective filter', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Query Complexity" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Query Complexity" tab="JavaScript" index=1 // RECOMMENDED: Simple, selective filter with indexed fields const filter = { type: 'messaging', @@ -251,7 +251,7 @@ describe(DOCS, () => { }); it('paginates with a members filter', async () => { - // #region snippet docs="_default/03-channels/02-query_channels.md" heading="Pagination Best Practices" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Pagination Best Practices" tab="JavaScript" index=1 // Efficient pagination with members filter const channels = await chatClient.queryChannelsAndHydrate({ filter_conditions: { type: 'messaging', members: { $in: [userId] } }, diff --git a/test/docs-snippets/pages/03-channels/03-channel_update.test.ts b/test/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts similarity index 85% rename from test/docs-snippets/pages/03-channels/03-channel_update.test.ts rename to test/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts index cafc7c4e86..b0fda327dd 100644 --- a/test/docs-snippets/pages/03-channels/03-channel_update.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts @@ -1,12 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../src'; +import type { StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; describe('_default/03-channels/03-channel_update.md', () => { const serverClient = getServerClient(); @@ -30,7 +30,7 @@ describe('_default/03-channels/03-channel_update.md', () => { const id = uniqueId('channel'); cleanup.channels.push(`${type}:${id}`); - // #region snippet docs="_default/03-channels/03-channel_update.md" heading="Partial Update" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/03-channel_update.md" heading="Partial Update" tab="JavaScript" index=1 // Here's a channel with some custom field data that might be useful const channel = client.channel(type, id, { custom: { @@ -67,7 +67,7 @@ describe('_default/03-channels/03-channel_update.md', () => { await channel.create(); if (channel.cid) cleanup.channels.push(channel.cid); - // #region snippet docs="_default/03-channels/03-channel_update.md" heading="Full Update" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/03-channel_update.md" heading="Full Update" tab="JavaScript" index=1 const update = await channel.update({ data: { custom: { name: 'myspecialchannel', color: 'green' } }, }); diff --git a/test/docs-snippets/pages/03-channels/04-query_members.test.ts b/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts similarity index 90% rename from test/docs-snippets/pages/03-channels/04-query_members.test.ts rename to test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts index c5c936d311..a4033a6cb1 100644 --- a/test/docs-snippets/pages/03-channels/04-query_members.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, MembersResponse, StreamChat } from '../../../../src'; -import { nsToDate } from '../../../../src'; +import type { Channel, MembersResponse, StreamChat } from '../../../../../src'; +import { nsToDate } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; const DOCS = '_default/03-channels/04-query_members.md'; @@ -52,7 +52,7 @@ describe(DOCS, () => { it('queries members by name, autocomplete and all', async () => { const spy = vi.spyOn(channel, 'queryMembers'); - // #region snippet docs="_default/03-channels/04-query_members.md" heading="" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/04-query_members.md" heading="" tab="JavaScript" index=1 // Query members by user name await channel.queryMembers({ payload: { filter_conditions: { name: 'tommaso' } } }); @@ -82,7 +82,7 @@ describe(DOCS, () => { const lastMember = newestFirst[0]; const spy = vi.spyOn(channel, 'queryMembers'); - // #region snippet docs="_default/03-channels/04-query_members.md" heading="Paginating Channel Members" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/04-query_members.md" heading="Paginating Channel Members" tab="JavaScript" index=1 // Returns up to 100 members ordered by created_at descending await channel.queryMembers({ payload: { sort: [{ field: 'created_at', direction: -1 }] }, diff --git a/test/docs-snippets/pages/03-channels/05-channel_pagination.test.ts b/test/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts similarity index 86% rename from test/docs-snippets/pages/03-channels/05-channel_pagination.test.ts rename to test/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts index 2760e9d700..66fc13e46d 100644 --- a/test/docs-snippets/pages/03-channels/05-channel_pagination.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts @@ -1,12 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Channel, StreamChat } from '../../../../src'; +import type { Channel, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; const DOCS = '_default/03-channels/05-channel_pagination.md'; const MESSAGE_COUNT = 30; @@ -48,7 +48,7 @@ describe(DOCS, () => { // The message the docs jump to (an app would get it from a search result or a link). const targetMessageId = messageIds[10]; - // #region snippet docs="_default/03-channels/05-channel_pagination.md" heading="Pagination Parameters" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/05-channel_pagination.md" heading="Pagination Parameters" tab="JavaScript" index=1 // COPY: targetMessageId="message-id" // Fetch the first page (the latest 20 messages) const { messages } = await channel.query({ @@ -79,7 +79,7 @@ describe(DOCS, () => { }); it('paginates members and watchers', async () => { - // #region snippet docs="_default/03-channels/05-channel_pagination.md" heading="Member and Watcher Pagination" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/05-channel_pagination.md" heading="Member and Watcher Pagination" tab="JavaScript" index=1 // Paginate members and watchers const result = await channel.query({ members: { limit: 20, offset: 0 }, diff --git a/test/docs-snippets/pages/03-channels/06-channel_members.test.ts b/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts similarity index 85% rename from test/docs-snippets/pages/03-channels/06-channel_members.test.ts rename to test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts index 2fde9c14b5..bf5311134a 100644 --- a/test/docs-snippets/pages/03-channels/06-channel_members.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Channel, StreamChat } from '../../../../src'; +import type { Channel, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerChannel } from '../../helpers/server'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerChannel } from '../../../helpers/server'; describe('_default/03-channels/06-channel_members.md', () => { const serverClient = getServerClient(); @@ -74,7 +74,7 @@ describe('_default/03-channels/06-channel_members.md', () => { it('adds members', async () => { const channel = await createOwnedChannel(); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Adding Members" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Adding Members" tab="JavaScript" index=1 // COPY: thierry="thierry", josh="josh", jamesBond="james_bond", alecTrevelyan="alec_trevelyan" await channel.addMembers([thierry, josh]); @@ -97,7 +97,7 @@ describe('_default/03-channels/06-channel_members.md', () => { const randomID = uniqueId('channel'); cleanup.channels.push(`messaging:${randomID}`); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Adding Members" tab="JavaScript" index=2 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Adding Members" tab="JavaScript" index=2 // COPY: jamesBond="james_bond", alecTrevelyan="alec_trevelyan" const channel = client.channel('messaging', randomID, { members: [ @@ -116,7 +116,7 @@ describe('_default/03-channels/06-channel_members.md', () => { it('removes members', async () => { const channel = await createOwnedChannel([tommaso]); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Removing Members" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Removing Members" tab="JavaScript" index=1 // COPY: tommaso="tommaso" await channel.removeMembers([tommaso]); // #endregion snippet @@ -136,7 +136,7 @@ describe('_default/03-channels/06-channel_members.md', () => { cleanup.channels.push(`messaging:${id}`); const channel = client.channel('messaging', id); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Leaving a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Leaving a Channel" tab="JavaScript" index=1 // COPY: owner="my_user_id" // Remove own channel membership await channel.removeMembers([owner]); @@ -149,7 +149,7 @@ describe('_default/03-channels/06-channel_members.md', () => { const channel = await createOwnedChannel(); await channel.sendMessage({ message: { text: 'Before thierry joined' } }); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Hide History" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Hide History" tab="JavaScript" index=1 // COPY: thierry="thierry" await channel.addMembers([thierry], undefined, { hide_history: true }); // #endregion snippet @@ -162,7 +162,7 @@ describe('_default/03-channels/06-channel_members.md', () => { const channel = await createOwnedChannel(); await channel.sendMessage({ message: { text: 'Sent today' } }); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Hide History Before a Specific Date" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Hide History Before a Specific Date" tab="JavaScript" index=1 // COPY: thierry="thierry" const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 7); // Last 7 days @@ -178,7 +178,7 @@ describe('_default/03-channels/06-channel_members.md', () => { it('adds members with a system message', async () => { const channel = await createOwnedChannel(); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="System Message Parameter" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="System Message Parameter" tab="JavaScript" index=1 // COPY: tommaso="tommaso" await channel.addMembers([tommaso], { text: 'Tommaso joined the channel.' }); // #endregion snippet @@ -196,7 +196,7 @@ describe('_default/03-channels/06-channel_members.md', () => { const randomID = uniqueId('channel'); cleanup.channels.push(`messaging:${randomID}`); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Adding Custom Data" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Adding Custom Data" tab="JavaScript" index=1 // COPY: userid1="userid1", userid2="userid2", userid3="userid3" // Add custom data while creating the channel const channel = client.channel('messaging', randomID, { @@ -222,7 +222,7 @@ describe('_default/03-channels/06-channel_members.md', () => { const channel = await createOwnedChannel(); await channel.updateMemberPartial({ set: { key3: 'value 3' } }); - // #region snippet docs="_default/03-channels/06-channel_members.md" heading="Updating Member Data" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Updating Member Data" tab="JavaScript" index=1 // Set some fields await channel.updateMemberPartial({ set: { diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts similarity index 81% rename from test/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts rename to test/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts index 654bc7d423..03b987f17a 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/08-archiving.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { StreamChat } from '../../../../../src'; +import type { StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerMember } from '../../../helpers/server'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerMember } from '../../../../helpers/server'; describe('_default/03-channels/07-channel_management/08-archiving.md', () => { const serverClient = getServerClient(); @@ -34,7 +34,7 @@ describe('_default/03-channels/07-channel_management/08-archiving.md', () => { it('archives, queries and unarchives a channel', async () => { const archiveSpy = vi.spyOn(client.channel('messaging', channelId), 'archive'); - // #region snippet docs="_default/03-channels/07-channel_management/08-archiving.md" heading="Archive a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/08-archiving.md" heading="Archive a Channel" tab="JavaScript" index=1 // COPY: channelId="example" // Get a channel const channel = client.channel('messaging', channelId); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts similarity index 85% rename from test/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts rename to test/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts index 297f35d072..4bcb554767 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/09-pinning.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../../src'; +import type { StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerMember } from '../../../helpers/server'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerMember } from '../../../../helpers/server'; describe('_default/03-channels/07-channel_management/09-pinning.md', () => { const serverClient = getServerClient(); @@ -43,7 +43,7 @@ describe('_default/03-channels/07-channel_management/09-pinning.md', () => { }); it('pins, queries and unpins a channel', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/09-pinning.md" heading="Pin a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/09-pinning.md" heading="Pin a Channel" tab="JavaScript" index=1 // COPY: channelId="example", amyId="amy", benId="ben" // Get a channel const channel = client.channel('messaging', channelId); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts similarity index 83% rename from test/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts rename to test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts index 6527442678..06a226803c 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/10-muting.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts @@ -1,15 +1,15 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { StreamChat } from '../../../../../src'; -import type { Channel } from '../../../../../src'; +import { StreamChat } from '../../../../../../src'; +import type { Channel } from '../../../../../../src'; import { createUserToken, disconnectClients, getServerClient, -} from '../../../helpers/clients'; -import type { TestUser } from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { retry } from '../../../helpers/wait'; +} from '../../../../helpers/clients'; +import type { TestUser } from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; describe('_default/03-channels/07-channel_management/10-muting.md', () => { const serverClient = getServerClient(); @@ -50,7 +50,7 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { }); it('connects, mutes a channel and reads its mute status', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Mute a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/10-muting.md" heading="Mute a Channel" tab="JavaScript" index=1 // COPY: channelId="channel-id" const reply = await client.connectUser(user, token); @@ -93,7 +93,7 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { await client.channel('messaging', channelId).mute(); const spy = vi.spyOn(client, 'queryChannelsAndHydrate'); - // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Query Muted Channels" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/10-muting.md" heading="Query Muted Channels" tab="JavaScript" index=1 // retrieve all channels excluding muted ones await client.queryChannelsAndHydrate({ filter_conditions: { members: { $in: [userId] }, muted: false }, @@ -116,7 +116,7 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { it('unmutes a channel', async () => { const channel = client.channel('messaging', channelId); - // #region snippet docs="_default/03-channels/07-channel_management/10-muting.md" heading="Remove a Channel Mute" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/10-muting.md" heading="Remove a Channel Mute" tab="JavaScript" index=1 // unmute channel for current user await channel.unmute(); // #endregion snippet diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts similarity index 88% rename from test/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts rename to test/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts index 1a4254187e..cf009bad2d 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/11-hiding.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Event, StreamChat } from '../../../../../src'; +import type { Event, StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel, sendServerMessage } from '../../../helpers/server'; -import { retry } from '../../../helpers/wait'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel, sendServerMessage } from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; describe('_default/03-channels/07-channel_management/11-hiding.md', () => { const serverClient = getServerClient(); @@ -49,7 +49,7 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { } }); - // #region snippet docs="_default/03-channels/07-channel_management/11-hiding.md" heading="Hide a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/11-hiding.md" heading="Hide a Channel" tab="JavaScript" index=1 // hides the channel until a new message is added there await channel.hide(); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts similarity index 88% rename from test/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts rename to test/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts index a4a7d2103f..a1dd8b97b7 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/12-disabling.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts @@ -1,12 +1,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, StreamChat } from '../../../../../src'; +import type { Channel, StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; describe('_default/03-channels/07-channel_management/12-disabling.md', () => { const serverClient = getServerClient(); @@ -36,7 +36,7 @@ describe('_default/03-channels/07-channel_management/12-disabling.md', () => { it('filters out disabled channels', async () => { const spy = vi.spyOn(client, 'queryChannelsAndHydrate'); - // #region snippet docs="_default/03-channels/07-channel_management/12-disabling.md" heading="" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/12-disabling.md" heading="" tab="JavaScript" index=1 await client.queryChannelsAndHydrate({ filter_conditions: { disabled: false } }); // #endregion snippet diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts similarity index 82% rename from test/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts rename to test/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts index b73a11fbb4..4304b5a2df 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/13-deleting.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts @@ -1,12 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../../src'; +import type { StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; describe('_default/03-channels/07-channel_management/13-deleting.md', () => { const serverClient = getServerClient(); @@ -40,7 +40,7 @@ describe('_default/03-channels/07-channel_management/13-deleting.md', () => { it('deletes a channel', async () => { const channel = await createChannel('general'); - // #region snippet docs="_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting a Channel" tab="JavaScript" index=1 const destroy = await channel.delete(); // #endregion snippet @@ -56,7 +56,7 @@ describe('_default/03-channels/07-channel_management/13-deleting.md', () => { const cid1 = first.cid; const cid2 = second.cid; - // #region snippet docs="_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting Many Channels" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting Many Channels" tab="JavaScript" index=1 // soft delete const response = await client.deleteChannels({ cids: [cid1, cid2] }); diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts similarity index 81% rename from test/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts rename to test/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts index 65d803681f..d435a52e01 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/14-freezing.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Channel, StreamChat } from '../../../../../src'; +import type { Channel, StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { sendServerMessage } from '../../../helpers/server'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { sendServerMessage } from '../../../../helpers/server'; describe('_default/03-channels/07-channel_management/14-freezing.md', () => { const serverClient = getServerClient(); @@ -43,7 +43,7 @@ describe('_default/03-channels/07-channel_management/14-freezing.md', () => { user_id: otherId, }); - // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Freeze a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/14-freezing.md" heading="Freeze a Channel" tab="JavaScript" index=1 const update = await channel.update({ data: { frozen: true }, message: { text: 'Thierry has frozen the channel' }, @@ -65,7 +65,7 @@ describe('_default/03-channels/07-channel_management/14-freezing.md', () => { }); it('unfreezes a channel', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/14-freezing.md" heading="Unfreeze a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/14-freezing.md" heading="Unfreeze a Channel" tab="JavaScript" index=1 const update = await channel.update({ data: { frozen: false }, message: { text: 'Thierry has unfrozen the channel' }, diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts similarity index 84% rename from test/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts rename to test/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts index 8a26fa6a55..b388eb1dee 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/15-truncating.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Channel, StreamChat } from '../../../../../src'; +import type { Channel, StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel } from '../../../helpers/server'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel } from '../../../../helpers/server'; describe('_default/03-channels/07-channel_management/15-truncating.md', () => { const serverClient = getServerClient(); @@ -55,7 +55,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { const channel = await createChannel(); await channel.sendMessage({ message: { text: 'first' } }); - // #region snippet docs="_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate a Channel" tab="JavaScript" index=1 await channel.truncate(); // Or with parameters: @@ -79,7 +79,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { const channel = await createChannel(); await channel.sendMessage({ message: { text: 'history' } }); - // #region snippet docs="_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate for Specific Members" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate for Specific Members" tab="JavaScript" index=1 // COPY: janeId="jane", johnId="john" // Hide the history for two members; everyone else keeps their full view await channel.truncate({ diff --git a/test/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts similarity index 80% rename from test/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts rename to test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts index 892ebe7a12..43215c402c 100644 --- a/test/docs-snippets/pages/03-channels/07-channel_management/16-channel_invites.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../../src'; +import type { StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerMember } from '../../../helpers/server'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerMember } from '../../../../helpers/server'; describe('_default/03-channels/07-channel_management/16-channel_invites.md', () => { const serverClient = getServerClient(); @@ -61,7 +61,7 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () }); await channel.create(); - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Invite Users" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Invite Users" tab="JavaScript" index=1 // COPY: nickId="nick" await channel.inviteMembers([nickId]); // #endregion snippet @@ -74,7 +74,7 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () it('accepts an invite', async () => { const channelId = (await createInviteChannel('awesome-chat')).id as string; - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Accept an Invite" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Accept an Invite" tab="JavaScript" index=1 // COPY: channelId="awesome-chat" // initialize the channel const channel = client.channel('messaging', channelId); @@ -98,7 +98,7 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () const channelId = (await createInviteChannel('general')).id as string; const channel = client.channel('messaging', channelId); - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Reject an Invite" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Reject an Invite" tab="JavaScript" index=1 await channel.rejectInvite(); // #endregion snippet @@ -108,7 +108,7 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () }); it('queries accepted invites', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Accepted Invites" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Accepted Invites" tab="JavaScript" index=1 const invites = await client.queryChannelsAndHydrate({ filter_conditions: { invite: 'accepted' }, }); @@ -121,7 +121,7 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () }); it('queries rejected invites', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Rejected Invites" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Rejected Invites" tab="JavaScript" index=1 const rejected = await client.queryChannelsAndHydrate({ filter_conditions: { invite: 'rejected' }, }); @@ -134,7 +134,7 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () }); it('queries pending invites', async () => { - // #region snippet docs="_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Pending Invites" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Pending Invites" tab="JavaScript" index=1 const pending = await client.queryChannelsAndHydrate({ filter_conditions: { invite: 'pending' }, }); diff --git a/test/docs-snippets/pages/04-messages/01-send_message.test.ts b/test/docs-snippets/pages/chat/04-messages/01-send_message.test.ts similarity index 83% rename from test/docs-snippets/pages/04-messages/01-send_message.test.ts rename to test/docs-snippets/pages/chat/04-messages/01-send_message.test.ts index a5e8e69605..3d3bde1c77 100644 --- a/test/docs-snippets/pages/04-messages/01-send_message.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/01-send_message.test.ts @@ -1,15 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Channel, StreamChat } from '../../../../src'; +import type { Channel, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { grantMessagingMembers } from '../../helpers/grants'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; -import { waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { grantMessagingMembers } from '../../../helpers/grants'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../helpers/wait'; // `messaging` doesn't grant channel members `CreateAttachment`, `CreateMention` or the // `Notify*` mention permissions in the test app: add them for this file and restore them. @@ -53,7 +53,7 @@ describe('_default/04-messages/01-send_message.md', () => { }); it('sends a message', async () => { - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 const message = await channel.sendMessage({ message: { text: 'Hello, world!' }, }); @@ -72,7 +72,7 @@ describe('_default/04-messages/01-send_message.md', () => { member_ids: [joshId], }); - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Mentions" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Mentions" tab="JavaScript" index=1 // COPY: designTeamId="design-team-id" // Send a message mentioning all online users const message = await channel.sendMessage({ @@ -111,7 +111,7 @@ describe('_default/04-messages/01-send_message.md', () => { // The docs' `josh` is a user the app already has. const josh = { id: joshId }; - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Sending Messages with Attachments" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending Messages with Attachments" tab="JavaScript" index=1 // Custom fields go under `custom`, on the message and on each attachment. const message = await channel.sendMessage({ message: { @@ -141,7 +141,7 @@ describe('_default/04-messages/01-send_message.md', () => { }); it('enriches URLs', async () => { - // #region snippet docs="_default/04-messages/01-send_message.md" heading="URL Enrichment" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="URL Enrichment" tab="JavaScript" index=1 const response = await channel.sendMessage({ message: { text: 'Check this out https://imgur.com/r/bears/4zmGbMN' }, }); @@ -156,7 +156,7 @@ describe('_default/04-messages/01-send_message.md', () => { await sendServerMessage(serverClient, cid, { text: 'to retrieve', user_id: userId }) ).id; - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Retrieving a Message" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Retrieving a Message" tab="JavaScript" index=1 const message = await client.getMessage({ id: messageID }); // #endregion snippet @@ -172,7 +172,7 @@ describe('_default/04-messages/01-send_message.md', () => { }) ).id; - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Updating a Message" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Updating a Message" tab="JavaScript" index=1 const updated = await client.updateMessage( { id: messageId }, { message: { text: 'Updated message text' } }, @@ -190,7 +190,7 @@ describe('_default/04-messages/01-send_message.md', () => { }, }); - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Partial Update" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Partial Update" tab="JavaScript" index=1 // Update text await client.updateMessagePartial( { id: originalMessage.id }, @@ -224,7 +224,7 @@ describe('_default/04-messages/01-send_message.md', () => { }) ).id; - // #region snippet docs="_default/04-messages/01-send_message.md" heading="Deleting a Message" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Deleting a Message" tab="JavaScript" index=1 // Soft delete await client.deleteMessage({ id: messageID }); diff --git a/test/docs-snippets/pages/04-messages/02-file_uploads.test.ts b/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts similarity index 89% rename from test/docs-snippets/pages/04-messages/02-file_uploads.test.ts rename to test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts index 5753bfc4a5..7da0dcde98 100644 --- a/test/docs-snippets/pages/04-messages/02-file_uploads.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts @@ -1,15 +1,15 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, FileLike, FileReference, StreamChat } from '../../../../src'; +import type { Channel, FileLike, FileReference, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { grantMessagingMembers } from '../../helpers/grants'; -import { uniqueId } from '../../helpers/ids'; -import { getServerUser } from '../../helpers/server'; -import { waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { grantMessagingMembers } from '../../../helpers/grants'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser } from '../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../helpers/wait'; // v10 builds multipart bodies with the global `FormData`, and Node's `File` is a `Blob`, so the // snippets' browser `File`s upload as-is under Node. @@ -61,7 +61,7 @@ describe('_default/04-messages/02-file_uploads.md', () => { const file2 = makeTextFile('notes.txt'); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Uploading Files to a Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Uploading Files to a Channel" tab="JavaScript" index=1 const response1 = await channel.uploadImage( { file: file1 }, // Optional @@ -123,7 +123,7 @@ describe('_default/04-messages/02-file_uploads.md', () => { const imageFile = makeImage('avatar.png'); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Uploading Standalone Files" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Uploading Standalone Files" tab="JavaScript" index=1 const { file: imageUrl } = await client.uploadImage( { file: imageFile }, // Optional @@ -166,7 +166,7 @@ describe('_default/04-messages/02-file_uploads.md', () => { const deleteFileSpy = vi.spyOn(channel, 'deleteFile'); const deleteImageSpy = vi.spyOn(channel, 'deleteImage'); - // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Deleting Files" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Deleting Files" tab="JavaScript" index=1 // Delete from channel await channel.deleteFile(fileURL); await channel.deleteImage(imageURL); @@ -199,7 +199,7 @@ describe('_default/04-messages/02-file_uploads.md', () => { messageComposer.registerSubscriptions(); try { - // #region snippet docs="_default/04-messages/02-file_uploads.md" heading="Using Your Own CDN" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Using Your Own CDN" tab="JavaScript" index=1 client.config.set({ messageComposer: { attachments: { diff --git a/test/docs-snippets/pages/04-messages/03-threads.test.ts b/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts similarity index 87% rename from test/docs-snippets/pages/04-messages/03-threads.test.ts rename to test/docs-snippets/pages/chat/04-messages/03-threads.test.ts index dc15cd5aed..148ebea9d0 100644 --- a/test/docs-snippets/pages/04-messages/03-threads.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts @@ -1,16 +1,16 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { StreamChat } from '../../../../src'; -import type { Channel } from '../../../../src'; +import { StreamChat } from '../../../../../src'; +import type { Channel } from '../../../../../src'; import { createUserToken, disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; describe('_default/04-messages/03-threads.md', () => { const serverClient = getServerClient(); @@ -86,7 +86,7 @@ describe('_default/04-messages/03-threads.md', () => { }); it('starts a thread', async () => { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Starting a Thread" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Starting a Thread" tab="JavaScript" index=1 const reply = await channel.sendMessage({ message: { text: 'This is a reply in a thread', @@ -103,7 +103,7 @@ describe('_default/04-messages/03-threads.md', () => { it('paginates thread replies', async () => { const olderReplyId = replyIds[2]; - // #region snippet docs="_default/04-messages/03-threads.md" heading="Paginating Thread Replies" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Paginating Thread Replies" tab="JavaScript" index=1 // COPY: olderReplyId="42" // Get the latest 20 replies const replies = await client.getReplies( @@ -133,7 +133,7 @@ describe('_default/04-messages/03-threads.md', () => { it('sends an inline reply', async () => { const originalMessageId = parentMessageId; - // #region snippet docs="_default/04-messages/03-threads.md" heading="Inline Replies" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Inline Replies" tab="JavaScript" index=1 const message = await channel.sendMessage({ message: { text: 'I agree with this point', @@ -147,7 +147,7 @@ describe('_default/04-messages/03-threads.md', () => { }); it('queries threads', async () => { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Querying Threads" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Querying Threads" tab="JavaScript" index=1 const { threads } = await client.queryThreadsAndHydrate(); for (const thread of threads) { @@ -166,7 +166,7 @@ describe('_default/04-messages/03-threads.md', () => { it('filters, sorts and paginates threads', async () => { const createdByUserId = userId; - // #region snippet docs="_default/04-messages/03-threads.md" heading="Supported Sort Fields" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Supported Sort Fields" tab="JavaScript" index=1 // COPY: createdByUserId="user-1" // Get threads created by a specific user, sorted by creation date const { threads, next } = await client.queryThreadsAndHydrate({ @@ -198,7 +198,7 @@ describe('_default/04-messages/03-threads.md', () => { }); it('gets a thread by id', async () => { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Getting a Thread by ID" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Getting a Thread by ID" tab="JavaScript" index=1 const thread = await client.getThreadAndHydrate(parentMessageId, { watch: true, reply_limit: 10, @@ -219,7 +219,7 @@ describe('_default/04-messages/03-threads.md', () => { it('updates the thread title and custom data', async () => { const threadId = parentMessageId; - // #region snippet docs="_default/04-messages/03-threads.md" heading="Updating Thread Title and Custom Data" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Updating Thread Title and Custom Data" tab="JavaScript" index=1 // Set properties const { thread } = await client.updateThreadPartial( { message_id: threadId }, @@ -256,7 +256,7 @@ describe('_default/04-messages/03-threads.md', () => { }); try { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Total Unread Threads" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Total Unread Threads" tab="JavaScript" index=1 // COPY: readerId="user-id" const response = await client.connectUser({ id: readerId }, token); console.log(response?.me?.unread_threads); @@ -293,7 +293,7 @@ describe('_default/04-messages/03-threads.md', () => { }); const markUnreadSpy = vi.spyOn(channel, 'markUnread'); - // #region snippet docs="_default/04-messages/03-threads.md" heading="Marking Threads as Read or Unread" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Marking Threads as Read or Unread" tab="JavaScript" index=1 // Mark thread as read await channel.markRead({ thread_id: parentMessageId }); @@ -314,7 +314,7 @@ describe('_default/04-messages/03-threads.md', () => { }); it('gets the unread count per thread', async () => { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Unread Count Per Thread" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Unread Count Per Thread" tab="JavaScript" index=1 const response = await client.unreadCounts(); console.log(response.total_unread_threads_count); @@ -331,7 +331,7 @@ describe('_default/04-messages/03-threads.md', () => { }); it('uses the thread manager', async () => { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Thread Manager" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Thread Manager" tab="JavaScript" index=1 // Access the client's thread manager const threadManager = client.threads; @@ -362,7 +362,7 @@ describe('_default/04-messages/03-threads.md', () => { }); it('handles events for a single thread', async () => { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Event Handling" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Event Handling" tab="JavaScript" index=1 const { threads } = await client.queryThreadsAndHydrate({ watch: true, limit: 10 }); const [thread] = threads; @@ -401,7 +401,7 @@ describe('_default/04-messages/03-threads.md', () => { const client = await getClientSideClient({ id: userId }); try { - // #region snippet docs="_default/04-messages/03-threads.md" heading="Event Handling" tab="JavaScript" index=2 + // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Event Handling" tab="JavaScript" index=2 const threadManager = client.threads; threadManager.registerSubscriptions(); diff --git a/test/docs-snippets/pages/04-messages/04-send_reaction.test.ts b/test/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts similarity index 88% rename from test/docs-snippets/pages/04-messages/04-send_reaction.test.ts rename to test/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts index e9a8a05d8c..c72b635cbc 100644 --- a/test/docs-snippets/pages/04-messages/04-send_reaction.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts @@ -1,12 +1,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, QueryReactionsResponse, StreamChat } from '../../../../src'; +import type { Channel, QueryReactionsResponse, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; describe('_default/04-messages/04-send_reaction.md', () => { const serverClient = getServerClient(); @@ -60,7 +60,7 @@ describe('_default/04-messages/04-send_reaction.md', () => { // A reaction of another type, which `enforce_unique` should replace. await channel.sendReaction({ id: messageID }, { reaction: { type: 'fire' } }); - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Sending a Reaction" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Sending a Reaction" tab="JavaScript" index=1 // Add a reaction const reaction = await channel.sendReaction( { id: messageID }, @@ -94,7 +94,7 @@ describe('_default/04-messages/04-send_reaction.md', () => { await channel.sendReaction({ id: messageID }, { reaction: { type: 'love' } }); expect((await getServerMessage(messageID)).reaction_counts).toEqual({ love: 1 }); - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Removing a Reaction" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Removing a Reaction" tab="JavaScript" index=1 await channel.deleteReaction({ id: messageID, type: 'love' }); // #endregion snippet @@ -108,7 +108,7 @@ describe('_default/04-messages/04-send_reaction.md', () => { const types = Array.from({ length: 13 }, (_, i) => `r${i + 1}`); await sendServerReactions(messageID, types, otherId); - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Paginating Reactions" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Paginating Reactions" tab="JavaScript" index=1 // Get the first 10 reactions const response = await client.getReactions({ id: messageID }, { limit: 10 }); @@ -136,7 +136,7 @@ describe('_default/04-messages/04-send_reaction.md', () => { const message = await getServerMessage(messageId); const spy = vi.spyOn(client, 'queryReactions'); - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Querying Reactions" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Querying Reactions" tab="JavaScript" index=1 // Query reactions by type await client.queryReactions({ id: message.id }, { filter: { type: 'like' } }); @@ -167,7 +167,7 @@ describe('_default/04-messages/04-send_reaction.md', () => { it('sends cumulative reactions', async () => { const messageID = await newMessageId('Clap for me'); - // #region snippet docs="_default/04-messages/04-send_reaction.md" heading="Cumulative Reactions" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Cumulative Reactions" tab="JavaScript" index=1 // User claps 5 times await channel.sendReaction( { id: messageID }, diff --git a/test/docs-snippets/pages/04-messages/05-pinned_messages.test.ts b/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts similarity index 89% rename from test/docs-snippets/pages/04-messages/05-pinned_messages.test.ts rename to test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts index e6b22d2a19..b9e93d4f7e 100644 --- a/test/docs-snippets/pages/04-messages/05-pinned_messages.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts @@ -1,18 +1,18 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { nsToDate } from '../../../../src'; +import { nsToDate } from '../../../../../src'; import type { Channel, StreamChat, TimestampNS, UpdateMessagePartialResponse, -} from '../../../../src'; +} from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; /** Server timestamps are unix nanoseconds; '' when unset. */ const isoString = (timestamp: TimestampNS | undefined) => @@ -62,7 +62,7 @@ describe('_default/04-messages/05-pinned_messages.md', () => { const unpinSpy = vi.spyOn(client, 'unpinMessage'); const before = Date.now(); - // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Pinning and Unpinning Messages" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/05-pinned_messages.md" heading="Pinning and Unpinning Messages" tab="JavaScript" index=1 // Create a pinned message const { message } = await channel.sendMessage({ message: { @@ -117,7 +117,7 @@ describe('_default/04-messages/05-pinned_messages.md', () => { }); await channel.sendMessage({ message: { text: 'Not pinned' } }); - // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Retrieving Pinned Messages" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/05-pinned_messages.md" heading="Retrieving Pinned Messages" tab="JavaScript" index=1 const channelState = await client.channel('messaging', channelId).query(); const pinnedMessages = channelState.pinned_messages; // #endregion snippet @@ -130,7 +130,7 @@ describe('_default/04-messages/05-pinned_messages.md', () => { it('paginates pinned messages', async () => { const channel = pagingChannel; - // #region snippet docs="_default/04-messages/05-pinned_messages.md" heading="Paginating Pinned Messages" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/05-pinned_messages.md" heading="Paginating Pinned Messages" tab="JavaScript" index=1 // First page, newest first const page1 = await channel.getPinnedMessages({ limit: 10, diff --git a/test/docs-snippets/pages/04-messages/06-search.test.ts b/test/docs-snippets/pages/chat/04-messages/06-search.test.ts similarity index 91% rename from test/docs-snippets/pages/04-messages/06-search.test.ts rename to test/docs-snippets/pages/chat/04-messages/06-search.test.ts index 3a7c3f5d12..24947118d1 100644 --- a/test/docs-snippets/pages/04-messages/06-search.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/06-search.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../src'; +import type { StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; describe('_default/04-messages/06-search.md', () => { const serverClient = getServerClient(); @@ -109,7 +109,7 @@ describe('_default/04-messages/06-search.md', () => { }); it('searches messages', async () => { - // #region snippet docs="_default/04-messages/06-search.md" heading="Searching Messages" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/06-search.md" heading="Searching Messages" tab="JavaScript" index=1 // COPY: userId="john" // Search for messages containing text const results = await client.search({ @@ -138,7 +138,7 @@ describe('_default/04-messages/06-search.md', () => { }); it('paginates search results with a cursor', async () => { - // #region snippet docs="_default/04-messages/06-search.md" heading="Pagination" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/06-search.md" heading="Pagination" tab="JavaScript" index=1 // COPY: myChannelCid="messaging:my-channel" const channelFilters = { cid: myChannelCid }; const messageFilters = { text: { $autocomplete: 'supercali' } }; diff --git a/test/docs-snippets/pages/04-messages/07-silent_messages.test.ts b/test/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts similarity index 83% rename from test/docs-snippets/pages/04-messages/07-silent_messages.test.ts rename to test/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts index 4c118f0193..e409a88535 100644 --- a/test/docs-snippets/pages/04-messages/07-silent_messages.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, StreamChat } from '../../../../src'; +import type { Channel, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { grantMessagingMembers } from '../../helpers/grants'; -import { uniqueId } from '../../helpers/ids'; -import { waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { grantMessagingMembers } from '../../../helpers/grants'; +import { uniqueId } from '../../../helpers/ids'; +import { waitForChannelTypePropagation } from '../../../helpers/wait'; // `messaging` doesn't grant channel members `CreateAttachment` or `CreateSystemMessage` in // the test app: add them for this file and restore them. @@ -55,7 +55,7 @@ describe('_default/04-messages/07-silent_messages.md', () => { const before = await unreadCountOfOther(); const sendSpy = vi.spyOn(channel, 'sendMessage'); - // #region snippet docs="_default/04-messages/07-silent_messages.md" heading="Silent Messages" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/07-silent_messages.md" heading="Silent Messages" tab="JavaScript" index=1 const message = { text: 'You completed your trip', silent: true, @@ -78,7 +78,7 @@ describe('_default/04-messages/07-silent_messages.md', () => { it('sends a system message', async () => { const sendSpy = vi.spyOn(channel, 'sendMessage'); - // #region snippet docs="_default/04-messages/07-silent_messages.md" heading="System Messages" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/07-silent_messages.md" heading="System Messages" tab="JavaScript" index=1 await channel.sendMessage({ message: { text: 'You completed your trip', diff --git a/test/docs-snippets/pages/04-messages/09-message_reminders.test.ts b/test/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts similarity index 92% rename from test/docs-snippets/pages/04-messages/09-message_reminders.test.ts rename to test/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts index df902a1812..1f0835dcd3 100644 --- a/test/docs-snippets/pages/04-messages/09-message_reminders.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts @@ -5,16 +5,16 @@ import type { ReminderFilters, SortParamRequest, StreamChat, -} from '../../../../src'; -import { nsToMs } from '../../../../src'; +} from '../../../../../src'; +import { nsToMs } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { retry } from '../../../helpers/wait'; const oneHour = 3600000; @@ -74,7 +74,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { const createSpy = vi.spyOn(client, 'createReminder'); const before = Date.now(); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Creating a Message Reminder" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Creating a Message Reminder" tab="JavaScript" index=1 // COPY: messageID="message-id", anotherMessageID="another-message-id" // Create a reminder with a specific due date (direct API call) await client.createReminder( @@ -117,7 +117,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { const updateSpy = vi.spyOn(client, 'updateReminder'); const before = Date.now(); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Updating a Message Reminder" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Updating a Message Reminder" tab="JavaScript" index=1 // COPY: messageID="message-id" // Update a reminder with a new due date (direct API call) await client.updateReminder( @@ -155,7 +155,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { await client.reminders.upsertReminder({ message_id: ids.deleteOther }); expect(client.reminders.getFromState(ids.deleteOther)).toBeDefined(); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Deleting a Message Reminder" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Deleting a Message Reminder" tab="JavaScript" index=1 // COPY: messageID="message-id", anotherMessageID="another-message-id" // Delete a reminder for a message with id 'message-id' (direct API call) await client.deleteReminder({ message_id: messageID }); @@ -174,7 +174,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { it('queries reminders', async () => { const querySpy = vi.spyOn(client, 'queryReminders'); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Querying Message Reminders" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Querying Message Reminders" tab="JavaScript" index=1 // Retrieve the first page of reminders for the current user (direct API call). await client.queryReminders(); @@ -204,7 +204,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { const logged = (text: string, messageId: string) => logSpy.mock.calls.some(([a, b]) => a === text && b === messageId); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Events" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Events" tab="JavaScript" index=1 client.on('reminder.created', (event) => { console.log('Reminder created for message:', event.message_id); }); @@ -270,7 +270,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { expect(timed.reminder.remind_at).toBeDefined(); const querySpy = vi.spyOn(client, 'queryReminders'); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Filtering Reminders" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Filtering Reminders" tab="JavaScript" index=1 // Direct API call // Create filter for overdue reminders await client.queryReminders({ @@ -372,7 +372,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { expect(prev).toBeDefined(); const querySpy = vi.spyOn(client, 'queryReminders'); - // #region snippet docs="_default/04-messages/09-message_reminders.md" heading="Pagination" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Pagination" tab="JavaScript" index=1 // Load the next page (direct API call). await client.queryReminders({ filter, sort, limit, next }); // limit is the page size diff --git a/test/docs-snippets/pages/04-messages/10-message_receipts.test.ts b/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts similarity index 88% rename from test/docs-snippets/pages/04-messages/10-message_receipts.test.ts rename to test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts index 58845562c0..5e462c135f 100644 --- a/test/docs-snippets/pages/04-messages/10-message_receipts.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts @@ -1,16 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Channel, EventPayload } from '../../../../src'; -import { StreamChat } from '../../../../src'; +import type { Channel, EventPayload } from '../../../../../src'; +import { StreamChat } from '../../../../../src'; import { createUserToken, disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerUser } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; const apiKey = process.env.STREAM_API_KEY as string; @@ -67,7 +67,7 @@ describe('_default/04-messages/10-message_receipts.md', () => { const chatClient = newChatClient(); const userToken = createUserToken(deliveryUserId); - // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="JavaScript" index=1 // COPY: deliveryUserId="john", userToken="{{ chat_user_token }}" // A client sets its own privacy settings on the user object when connecting await chatClient.connectUser( @@ -105,7 +105,7 @@ describe('_default/04-messages/10-message_receipts.md', () => { const chatClient = newChatClient(); const userToken = createUserToken(readUserId); - // #region snippet docs="_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="JavaScript" index=2 + // #region snippet docs="chat/_default/04-messages/10-message_receipts.md" heading="User Privacy Settings" tab="JavaScript" index=2 // COPY: readUserId="john", userToken="{{ chat_user_token }}" // A client sets its own privacy settings on the user object when connecting await chatClient.connectUser( diff --git a/test/docs-snippets/pages/05-features/02-events.test.ts b/test/docs-snippets/pages/chat/05-features/02-events.test.ts similarity index 90% rename from test/docs-snippets/pages/05-features/02-events.test.ts rename to test/docs-snippets/pages/chat/05-features/02-events.test.ts index 824e7fc0be..a35d7b1111 100644 --- a/test/docs-snippets/pages/05-features/02-events.test.ts +++ b/test/docs-snippets/pages/chat/05-features/02-events.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, Event, EventPayload, StreamChat } from '../../../../src'; +import type { Channel, Event, EventPayload, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; describe('_default/05-features/02-events.md', () => { const serverClient = getServerClient(); @@ -53,7 +53,7 @@ describe('_default/05-features/02-events.md', () => { const channel = await createWatchedChannel(); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/05-features/02-events.md" heading="Listening for Events" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/02-events.md" heading="Listening for Events" tab="JavaScript" index=1 channel.on('message.new', (event) => { console.log('received a new message', event.message?.text); }); @@ -81,7 +81,7 @@ describe('_default/05-features/02-events.md', () => { const channel = await createWatchedChannel(); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/05-features/02-events.md" heading="Listening for Events" tab="JavaScript" index=2 + // #region snippet docs="chat/_default/05-features/02-events.md" heading="Listening for Events" tab="JavaScript" index=2 channel.on((event) => { console.log('event', event); console.log('channel.state', channel.state); @@ -115,7 +115,7 @@ describe('_default/05-features/02-events.md', () => { let presenceListener: { unsubscribe: () => void } | undefined; const onSpy = vi.spyOn(client, 'on'); try { - // #region snippet docs="_default/05-features/02-events.md" heading="Event Types" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/02-events.md" heading="Event Types" tab="JavaScript" index=1 // COPY: channelId="my-conversation-123", johnId="john", jackId="jack" // Watch a channel with presence enabled to receive user presence events const channel = client.channel('messaging', channelId, { @@ -160,7 +160,7 @@ describe('_default/05-features/02-events.md', () => { const subscribeSpy = vi.spyOn(client.wsConnection.state, 'subscribeWithSelector'); let unsubscribe: (() => void) | undefined; try { - // #region snippet docs="_default/05-features/02-events.md" heading="Connection Events" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/02-events.md" heading="Connection Events" tab="JavaScript" index=1 // the WebSocket's status is a state store: the handler runs once with // the current value, then on every change client.wsConnection.state.subscribeWithSelector( @@ -201,7 +201,7 @@ describe('_default/05-features/02-events.md', () => { const myClientEventHandler = vi.fn(); const myChannelEventHandler = vi.fn(); - // #region snippet docs="_default/05-features/02-events.md" heading="Stop Listening for Events" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/02-events.md" heading="Stop Listening for Events" tab="JavaScript" index=1 // remove the handler from all client events const myClientEventListener = client.on('message.new', myClientEventHandler); myClientEventListener.unsubscribe(); @@ -241,7 +241,7 @@ describe('_default/05-features/02-events.md', () => { const received: EventPayload<'friendship_request'>[] = []; otherChannel.on('friendship_request', (event) => received.push(event)); - // #region snippet docs="_default/05-features/02-events.md" heading="To a channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/02-events.md" heading="To a channel" tab="JavaScript" index=1 // sends an event for the current user to all connect clients on the channel await channel.sendEvent({ event: { diff --git a/test/docs-snippets/pages/05-features/03-unread.test.ts b/test/docs-snippets/pages/chat/05-features/03-unread.test.ts similarity index 84% rename from test/docs-snippets/pages/05-features/03-unread.test.ts rename to test/docs-snippets/pages/chat/05-features/03-unread.test.ts index ec4301a905..2c2fee6ac8 100644 --- a/test/docs-snippets/pages/05-features/03-unread.test.ts +++ b/test/docs-snippets/pages/chat/05-features/03-unread.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { StreamChat } from '../../../../src'; +import { StreamChat } from '../../../../../src'; import { createUserToken, disconnectClients, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { sendServerMessage } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { sendServerMessage } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; describe('_default/05-features/03-unread.md', () => { const serverClient = getServerClient(); @@ -57,7 +57,7 @@ describe('_default/05-features/03-unread.md', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); const onSpy = vi.spyOn(client, 'on'); try { - // #region snippet docs="_default/05-features/03-unread.md" heading="Reading Unread Counts" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Reading Unread Counts" tab="JavaScript" index=1 // COPY: userId="myid" // Step 1: Get initial unread counts when connecting const user = await client.connectUser({ id: userId }, token); @@ -102,7 +102,7 @@ describe('_default/05-features/03-unread.md', () => { it('fetches unread counts without a WS connection', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/05-features/03-unread.md" heading="Unread Counts - Server side" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Unread Counts - Server side" tab="JavaScript" index=1 const response = await client.unreadCounts(); console.log(response.total_unread_count); // total unread count for user console.log(response.channels); // distribution of unread counts across channels @@ -133,7 +133,7 @@ describe('_default/05-features/03-unread.md', () => { await channel.watch(); expect(await serverUnread()).toBe(2); - // #region snippet docs="_default/05-features/03-unread.md" heading="Mark Read" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Mark Read" tab="JavaScript" index=1 // mark all messages on a channel as read await channel.markRead(); // #endregion snippet @@ -145,7 +145,7 @@ describe('_default/05-features/03-unread.md', () => { const channel = client.channel('messaging', channelId); expect(await serverUnread()).toBe(0); - // #region snippet docs="_default/05-features/03-unread.md" heading="Mark Read" tab="JavaScript" index=2 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Mark Read" tab="JavaScript" index=2 // COPY: firstMessageId="" await channel.markUnread({ message_id: firstMessageId }); // #endregion snippet @@ -157,7 +157,7 @@ describe('_default/05-features/03-unread.md', () => { it('marks all channels read', async () => { expect(await serverUnread()).toBe(2); - // #region snippet docs="_default/05-features/03-unread.md" heading="Mark All As Read" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Mark All As Read" tab="JavaScript" index=1 // client-side await client.markChannelsRead(); // #endregion snippet @@ -168,7 +168,7 @@ describe('_default/05-features/03-unread.md', () => { it('shows how far other users have read', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/05-features/03-unread.md" heading="Read State - Showing how far other users have read" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Read State - Showing how far other users have read" tab="JavaScript" index=1 // COPY: channelId="test" const channel = client.channel('messaging', channelId); await channel.watch(); @@ -212,11 +212,11 @@ describe('_default/05-features/03-unread.md', () => { const countSpy = vi.spyOn(channel, 'countUnread'); const mentionsSpy = vi.spyOn(channel, 'countUnreadMentions'); try { - // #region snippet docs="_default/05-features/03-unread.md" heading="Unread Messages Per Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Unread Messages Per Channel" tab="JavaScript" index=1 channel.countUnread(); // #endregion snippet - // #region snippet docs="_default/05-features/03-unread.md" heading="Unread Mentions Per Channel" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Unread Mentions Per Channel" tab="JavaScript" index=1 channel.countUnreadMentions(); // #endregion snippet diff --git a/test/docs-snippets/pages/05-features/04-typing_indicators.test.ts b/test/docs-snippets/pages/chat/05-features/04-typing_indicators.test.ts similarity index 91% rename from test/docs-snippets/pages/05-features/04-typing_indicators.test.ts rename to test/docs-snippets/pages/chat/05-features/04-typing_indicators.test.ts index 9e5cbafe01..186c3c409a 100644 --- a/test/docs-snippets/pages/05-features/04-typing_indicators.test.ts +++ b/test/docs-snippets/pages/chat/05-features/04-typing_indicators.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, EventPayload, StreamChat } from '../../../../src'; +import type { Channel, EventPayload, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { retry } from '../../../helpers/wait'; describe('_default/05-features/04-typing_indicators.md', () => { const serverClient = getServerClient(); @@ -58,7 +58,7 @@ describe('_default/05-features/04-typing_indicators.md', () => { const received = recordTypingEvents(otherChannel); const thread_id = parentId; try { - // #region snippet docs="_default/05-features/04-typing_indicators.md" heading="Sending Typing Events" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/04-typing_indicators.md" heading="Sending Typing Events" tab="JavaScript" index=1 // The JS client keeps track of the typing state for you. // Just call `channel.keystroke()` when the user types and // `channel.stopTyping()` when the user sends the message (or aborts) @@ -107,7 +107,7 @@ describe('_default/05-features/04-typing_indicators.md', () => { const { channel, otherChannel, parentId } = await createWatchedChannels(); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/05-features/04-typing_indicators.md" heading="Receiving typing indicator events" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/04-typing_indicators.md" heading="Receiving typing indicator events" tab="JavaScript" index=1 // channels keep track of the users that are currently typing. // `channel.state.typing` is an immutable object which gets regenerated // every time a new user is added or removed to this list diff --git a/test/docs-snippets/pages/05-features/05-presence_format.test.ts b/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts similarity index 89% rename from test/docs-snippets/pages/05-features/05-presence_format.test.ts rename to test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts index ddcb994a60..9d5a94fa11 100644 --- a/test/docs-snippets/pages/05-features/05-presence_format.test.ts +++ b/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts @@ -1,16 +1,16 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { StreamChat } from '../../../../src'; -import type { EventPayload } from '../../../../src'; +import { StreamChat } from '../../../../../src'; +import type { EventPayload } from '../../../../../src'; import { createUserToken, disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { getServerUser } from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { getServerUser } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; describe('_default/05-features/05-presence_format.md', () => { const serverClient = getServerClient(); @@ -51,7 +51,7 @@ describe('_default/05-features/05-presence_format.md', () => { it('watches and queries channels with presence', async () => { cleanup.channels.push(`messaging:${channelId}`); - // #region snippet docs="_default/05-features/05-presence_format.md" heading="Listening to Presence Changes" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/05-presence_format.md" heading="Listening to Presence Changes" tab="JavaScript" index=1 // COPY: channelId="my-conversation-123", johnId="john", jackId="jack" // If you pass presence: true to channel.watch it will watch the list of user presence changes. // Note that you can listen to at most 10 users using this API call @@ -119,7 +119,7 @@ describe('_default/05-features/05-presence_format.md', () => { }); const client = userClient; - // #region snippet docs="_default/05-features/05-presence_format.md" heading="Invisible" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/05-presence_format.md" heading="Invisible" tab="JavaScript" index=1 // COPY: userId="unique_user_id" // become invisible await client.updateUsersPartial({ @@ -164,7 +164,7 @@ describe('_default/05-features/05-presence_format.md', () => { return connectSpy.reply; }; - // #region snippet docs="_default/05-features/05-presence_format.md" heading="Invisible" tab="JavaScript" index=2 + // #region snippet docs="chat/_default/05-features/05-presence_format.md" heading="Invisible" tab="JavaScript" index=2 // COPY: userId="unique_user_id" // mark a user as invisible await client.connectUser( @@ -197,7 +197,7 @@ describe('_default/05-features/05-presence_format.md', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/05-features/05-presence_format.md" heading="Presence Data Format" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/05-presence_format.md" heading="Presence Data Format" tab="JavaScript" index=1 // COPY: presenceChannelId="my-channel-id", jackId="jack" const channel = client.channel('messaging', presenceChannelId); await channel.watch(); diff --git a/test/docs-snippets/pages/05-features/07-polls_api.test.ts b/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts similarity index 87% rename from test/docs-snippets/pages/05-features/07-polls_api.test.ts rename to test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts index df773e0e89..a83ee74ab1 100644 --- a/test/docs-snippets/pages/05-features/07-polls_api.test.ts +++ b/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, CreatePollRequest, StreamChat } from '../../../../src'; +import type { Channel, CreatePollRequest, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { deletePollsCreatedBy } from '../../helpers/server'; -import { waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { deletePollsCreatedBy } from '../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../helpers/wait'; const DOCS = '_default/05-features/07-polls_api.md'; @@ -71,7 +71,7 @@ describe(DOCS, () => { const firstOptionId = optionIds[0]; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Polls at a Quick Glance" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Polls at a Quick Glance" tab="JavaScript" index=1 // COPY: firstOptionId="option-id" // Create a poll and send it in a message const { poll } = await client.createPoll({ @@ -102,7 +102,7 @@ describe(DOCS, () => { }); it('creates a poll and sends it as part of a message', async () => { - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Creating a poll and sending it as part of a message" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Creating a poll and sending it as part of a message" tab="JavaScript" index=1 const { poll } = await client.createPoll({ name: 'Where should we host our next company event?', options: [ @@ -147,7 +147,7 @@ describe(DOCS, () => { }); it('creates a poll with custom properties', async () => { - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Poll options" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Poll options" tab="JavaScript" index=1 const poll = await client.createPoll({ name: 'Where should we host our next company event?', options: [ @@ -176,7 +176,7 @@ describe(DOCS, () => { // Load the poll into the PollManager cache, as rendering the channel would. await chatClient.polls.getPoll(pollId); - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Send vote on option" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Send vote on option" tab="JavaScript" index=1 // COPY: firstOptionId="some-option-id-328904342" const { vote } = await chatClient.castPollVote( { message_id: messageId, poll_id: pollId }, @@ -203,7 +203,7 @@ describe(DOCS, () => { await chatClient.polls.getPoll(pollId); const answerText = 'Somewhere sunny'; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Send an answer (if answers are configured to be allowed)" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Send an answer (if answers are configured to be allowed)" tab="JavaScript" index=1 const { vote } = await chatClient.castPollVote( { message_id: messageId, poll_id: pollId }, { vote: { answer_text: 'some-option-id-328904342' } }, @@ -239,7 +239,7 @@ describe(DOCS, () => { const anotherVoteId = await castVote(optionIds[1]); await chatClient.polls.getPoll(pollId); - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Removing a vote" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Removing a vote" tab="JavaScript" index=1 const { vote } = await chatClient.deletePollVote({ message_id: messageId, poll_id: pollId, @@ -264,7 +264,7 @@ describe(DOCS, () => { const updatePollPartial = vi.spyOn(chatClient, 'updatePollPartial'); try { - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Closing a poll" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Closing a poll" tab="JavaScript" index=1 await chatClient.updatePollPartial( { poll_id: pollId }, { set: { is_closed: true } }, @@ -290,7 +290,7 @@ describe(DOCS, () => { const { pollId } = await sendPoll(); const chatClient = client; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Retrieving a poll" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Retrieving a poll" tab="JavaScript" index=1 const poll = await chatClient.getPoll({ poll_id: pollId }); // or if you're using PollManager @@ -310,7 +310,7 @@ describe(DOCS, () => { } = await sendPoll(); const chatClient = client; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Full update" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Full update" tab="JavaScript" index=1 const updatedPoll = await chatClient.updatePoll({ id: pollId, name: 'Where should we not go to?', @@ -340,7 +340,7 @@ describe(DOCS, () => { const { pollId } = await sendPoll({ custom: { custom_property: 'value' } }); const chatClient = client; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Partial update" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Partial update" tab="JavaScript" index=1 const updatedPoll = await chatClient.updatePollPartial( { poll_id: pollId }, { @@ -360,7 +360,7 @@ describe(DOCS, () => { const chatClient = client; await chatClient.polls.getPoll(anotherPollId); - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Deleting a poll" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Deleting a poll" tab="JavaScript" index=1 await chatClient.deletePoll({ poll_id: pollId }); // or if you're using the reactive poll instance @@ -378,7 +378,7 @@ describe(DOCS, () => { const chatClient = client; await chatClient.polls.getPoll(pollId); - // #region snippet docs="_default/05-features/07-polls_api.md" heading="**Add poll option**" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="**Add poll option**" tab="JavaScript" index=1 const pollOption = await chatClient.createPollOption( { poll_id: pollId }, { text: 'Another option' }, @@ -405,7 +405,7 @@ describe(DOCS, () => { const chatClient = client; await chatClient.polls.getPoll(pollId); - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Update poll option" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Update poll option" tab="JavaScript" index=1 const updatedPollOption = await chatClient.updatePollOption( { poll_id: pollId }, { @@ -442,7 +442,7 @@ describe(DOCS, () => { const chatClient = client; await chatClient.polls.getPoll(pollId); - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Delete poll option" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Delete poll option" tab="JavaScript" index=1 await chatClient.deletePollOption({ poll_id: pollId, option_id: optionId }); // or if you're using the reactive poll instance @@ -468,7 +468,7 @@ describe(DOCS, () => { ); } - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Querying votes" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Querying votes" tab="JavaScript" index=1 // retrieve all votes on either option1Id or option2Id const { votes } = await chatClient.queryPollVotes( { poll_id: poll.id }, @@ -490,7 +490,7 @@ describe(DOCS, () => { ); const chatClient = client; - // #region snippet docs="_default/05-features/07-polls_api.md" heading="Querying polls" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Querying polls" tab="JavaScript" index=1 // retrieve all polls that are closed for voting sorted by created_at const { polls } = await chatClient.queryPolls({ filter: { is_closed: true }, diff --git a/test/docs-snippets/pages/05-features/08-location_sharing.test.ts b/test/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts similarity index 85% rename from test/docs-snippets/pages/05-features/08-location_sharing.test.ts rename to test/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts index 14afcfcfe0..d1437d51be 100644 --- a/test/docs-snippets/pages/05-features/08-location_sharing.test.ts +++ b/test/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts @@ -1,15 +1,15 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { LiveLocationManager } from '../../../../src'; -import type { Coords, StreamChat } from '../../../../src'; +import { LiveLocationManager } from '../../../../../src'; +import type { Coords, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; -import { grantMessagingMembers } from '../../helpers/grants'; -import { retry, waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { grantMessagingMembers } from '../../../helpers/grants'; +import { retry, waitForChannelTypePropagation } from '../../../helpers/wait'; describe('_default/05-features/08-location_sharing.md', () => { const serverClient = getServerClient(); @@ -46,7 +46,7 @@ describe('_default/05-features/08-location_sharing.md', () => { it('sends a static location', async () => { const messageId = uniqueId('message'); - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Sending static location" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Sending static location" tab="JavaScript" index=1 // COPY: channelType="type", channelId="id", messageId="message-id" const channel = client.channel(channelType, channelId); @@ -68,7 +68,7 @@ describe('_default/05-features/08-location_sharing.md', () => { const messageId = uniqueId('message'); { - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Starting live location sharing" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Starting live location sharing" tab="JavaScript" index=1 // COPY: channelType="type", channelId="id", messageId="message-id" const channel = client.channel(channelType, channelId); @@ -90,7 +90,7 @@ describe('_default/05-features/08-location_sharing.md', () => { expect(endAtMs).toBeGreaterThan(Date.now() + 50 * 60 * 1000); expect(endAtMs).toBeLessThan(Date.now() + 70 * 60 * 1000); - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Updating live location" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Updating live location" tab="JavaScript" index=1 // COPY: messageId="message-id" // simple call to update a location await client.updateLiveLocation({ @@ -106,7 +106,7 @@ describe('_default/05-features/08-location_sharing.md', () => { ).toMatchObject({ latitude: 1, longitude: 2 }); { - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Stopping live location sharing" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Stopping live location sharing" tab="JavaScript" index=1 // COPY: channelType="type", channelId="id", messageId="message-id" const channel = client.channel(channelType, channelId); @@ -130,7 +130,7 @@ describe('_default/05-features/08-location_sharing.md', () => { callback({ coords }); const getDeviceId = () => 'device-id'; - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Updating live location" tab="JavaScript" index=2 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Updating live location" tab="JavaScript" index=2 // the manager takes care of registering/ unregistering and reporting location updates const manager = new LiveLocationManager({ client, @@ -174,7 +174,7 @@ describe('_default/05-features/08-location_sharing.md', () => { const onSpy = vi.spyOn(channel, 'on'); try { - // #region snippet docs="_default/05-features/08-location_sharing.md" heading="Events" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Events" tab="JavaScript" index=1 // Listen for new and updated location messages on the watched channel. channel.on('message.new', (event) => { if (event.message?.shared_location) { diff --git a/test/docs-snippets/pages/05-features/09-translation.test.ts b/test/docs-snippets/pages/chat/05-features/09-translation.test.ts similarity index 88% rename from test/docs-snippets/pages/05-features/09-translation.test.ts rename to test/docs-snippets/pages/chat/05-features/09-translation.test.ts index 4bb54d5720..daf67443f2 100644 --- a/test/docs-snippets/pages/05-features/09-translation.test.ts +++ b/test/docs-snippets/pages/chat/05-features/09-translation.test.ts @@ -1,20 +1,20 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { StreamChat } from '../../../../src'; -import type { Channel, MessageResponse } from '../../../../src'; +import { StreamChat } from '../../../../../src'; +import type { Channel, MessageResponse } from '../../../../../src'; import { createUserToken, disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; import { deletePollsCreatedBy, getServerChannel, getServerUser, -} from '../../helpers/server'; -import { waitForChannelTypePropagation } from '../../helpers/wait'; +} from '../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../helpers/wait'; const DOCS = '_default/05-features/09-translation.md'; @@ -86,7 +86,7 @@ describe(DOCS, () => { const messageID = uniqueId('message'); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/05-features/09-translation.md" heading="Message Translation Endpoint" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/09-translation.md" heading="Message Translation Endpoint" tab="JavaScript" index=1 await channel.sendMessage({ message: { id: messageID, @@ -122,7 +122,7 @@ describe(DOCS, () => { cleanup.channels.push(`messaging:${meltingPotId}`); const client = languageClient; - // #region snippet docs="_default/05-features/09-translation.md" heading="Set user language" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/09-translation.md" heading="Set user language" tab="JavaScript" index=1 // COPY: languageUserId="userId", meltingPotId="melting-pot" // sets the user language await client.connectUser({ id: languageUserId, language: 'en' }, userToken); @@ -147,7 +147,7 @@ describe(DOCS, () => { .message; const client = spanishClient; - // #region snippet docs="_default/05-features/09-translation.md" heading="Poll translation" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/09-translation.md" heading="Poll translation" tab="JavaScript" index=1 const poll = message.poll; const language = client.user?.language; diff --git a/test/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts b/test/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts similarity index 86% rename from test/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts rename to test/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts index 1ce64dcd00..46bb76e61f 100644 --- a/test/docs-snippets/pages/05-features/10-advanced/11-slow_mode_and_throttling.test.ts +++ b/test/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts @@ -1,14 +1,14 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { MessageRequest, StreamChat } from '../../../../../src'; +import type { MessageRequest, StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel } from '../../../helpers/server'; -import { retry } from '../../../helpers/wait'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel } from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () => { const serverClient = getServerClient(); @@ -42,7 +42,7 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = .update({ add_moderators: [userId] }); const enableSpy = vi.spyOn(channel, 'enableSlowMode'); - // #region snippet docs="_default/05-features/10-advanced/11-slow_mode_and_throttling.md" heading="Channel Slow Mode" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md" heading="Channel Slow Mode" tab="JavaScript" index=1 // enable slow mode and set cooldown to 1s await channel.enableSlowMode(1); @@ -80,7 +80,7 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = const disableSendMessageUI = () => calls.push(['disable', Date.now()]); const enableSendMessageUI = () => calls.push(['enable', Date.now()]); - // #region snippet docs="_default/05-features/10-advanced/11-slow_mode_and_throttling.md" heading="Channel Slow Mode" tab="JavaScript" index=2 + // #region snippet docs="chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md" heading="Channel Slow Mode" tab="JavaScript" index=2 const p = channel.sendMessage({ message }); const cooldown = channel.data?.cooldown; diff --git a/test/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts b/test/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts similarity index 87% rename from test/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts rename to test/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts index f1be10d7a4..b590f1e8df 100644 --- a/test/docs-snippets/pages/05-features/10-advanced/12-drafts.test.ts +++ b/test/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, StreamChat } from '../../../../../src'; +import type { Channel, StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { retry } from '../../../helpers/wait'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; describe('_default/05-features/10-advanced/12-drafts.md', () => { const serverClient = getServerClient(); @@ -46,7 +46,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const channel = await createChannel(); const parentMessageId = await sendParent(channel); - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Creating a draft message" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Creating a draft message" tab="JavaScript" index=1 const draft = await channel.createDraft({ message: { text: 'this is a draft message' }, }); @@ -82,7 +82,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { message: { text: 'thread draft', parent_id: parentMessageId }, }); - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Deleting a draft message" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Deleting a draft message" tab="JavaScript" index=1 // Channel draft await channel.deleteDraft(); @@ -105,7 +105,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { message: { text: 'thread draft', parent_id: parentMessageId }, }); - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Loading a draft message" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Loading a draft message" tab="JavaScript" index=1 // Channel draft const draft = await channel.getDraft(); @@ -131,7 +131,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const channel2Cid = channel2.cid; const querySpy = vi.spyOn(client, 'queryDrafts'); - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Querying draft messages" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Querying draft messages" tab="JavaScript" index=1 // COPY: channel1Cid="messaging:channel-1", channel2Cid="messaging:channel-2" // Query all user drafts const response = await client.queryDrafts({}); @@ -177,7 +177,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { }); } - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Pagination" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Pagination" tab="JavaScript" index=1 // Query drafts with a limit const firstPage = await client.queryDrafts({ limit: 5, @@ -204,7 +204,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); const onSpy = vi.spyOn(client, 'on'); try { - // #region snippet docs="_default/05-features/10-advanced/12-drafts.md" heading="Events" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Events" tab="JavaScript" index=1 client.on('draft.updated', (event) => { // Handle event console.log('event', event); diff --git a/test/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts b/test/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts similarity index 81% rename from test/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts rename to test/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts index fddd114218..6c529942db 100644 --- a/test/docs-snippets/pages/05-features/10-advanced/13-private_messaging.test.ts +++ b/test/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts @@ -1,15 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../../src'; +import type { StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { grantMessagingMembers } from '../../../helpers/grants'; -import { uniqueId } from '../../../helpers/ids'; -import { getServerChannel } from '../../../helpers/server'; -import { waitForChannelTypePropagation } from '../../../helpers/wait'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { grantMessagingMembers } from '../../../../helpers/grants'; +import { uniqueId } from '../../../../helpers/ids'; +import { getServerChannel } from '../../../../helpers/server'; +import { waitForChannelTypePropagation } from '../../../../helpers/wait'; // `messaging` doesn't grant channel members `CreateSystemMessage` or // `CreateRestrictedVisibilityMessage` in the test app: add them for this file and restore them. @@ -56,7 +56,7 @@ describe('_default/05-features/10-advanced/13-private_messaging.md', () => { }); it('sends a message with restricted visibility', async () => { - // #region snippet docs="_default/05-features/10-advanced/13-private_messaging.md" heading="Sending a message with restricted visibility" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/13-private_messaging.md" heading="Sending a message with restricted visibility" tab="JavaScript" index=1 // COPY: channelId="ride-08467339", janeId="jane" // Get a channel const channel = client.channel('messaging', channelId); diff --git a/test/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts b/test/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts similarity index 85% rename from test/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts rename to test/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts index c3fae40920..f3bba46bbd 100644 --- a/test/docs-snippets/pages/05-features/10-advanced/15-pending_messages.test.ts +++ b/test/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../../src'; +import type { StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../../helpers/clients'; -import { Cleanup } from '../../../helpers/cleanup'; -import { uniqueId } from '../../../helpers/ids'; -import { sendServerMessage } from '../../../helpers/server'; +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { sendServerMessage } from '../../../../helpers/server'; describe('_default/05-features/10-advanced/15-pending_messages.md', () => { const serverClient = getServerClient(); @@ -51,7 +51,7 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { const pendingMessageId = pendingMessageId1; const channel = client.channel('messaging', channelId); - // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Querying pending messages" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Querying pending messages" tab="JavaScript" index=1 // COPY: pendingMessageId="pending_message_id", pendingMessageId1="pending_message_id_1", pendingMessageId2="pending_message_id_2" // To retrieve a single message const response = await client.getMessage({ id: pendingMessageId }); @@ -78,7 +78,7 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { }); it("returns each channel's pending messages from queryChannels", async () => { - // #region snippet docs="_default/05-features/10-advanced/15-pending_messages.md" heading="Query channels" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Query channels" tab="JavaScript" index=1 // COPY: userId="thierry" // Querying multiple channels returns each channel's pending messages const channels = await client.queryChannelsAndHydrate({ diff --git a/test/docs-snippets/pages/12-best_practices/02-moderation.test.ts b/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts similarity index 84% rename from test/docs-snippets/pages/12-best_practices/02-moderation.test.ts rename to test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts index 2df00fbbed..c67241925c 100644 --- a/test/docs-snippets/pages/12-best_practices/02-moderation.test.ts +++ b/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts @@ -1,18 +1,18 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { StreamChat } from '../../../../src'; +import type { StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, getServerClient, -} from '../../helpers/clients'; -import { Cleanup } from '../../helpers/cleanup'; -import { uniqueId } from '../../helpers/ids'; +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; import { getServerChannel, getServerMember, sendServerMessage, -} from '../../helpers/server'; -import { retry } from '../../helpers/wait'; +} from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; const DOCS = '_default/12-best_practices/02-moderation.md'; @@ -73,7 +73,7 @@ describe(DOCS, () => { cleanup.channels.push(`messaging:${id}`); await channel.create(); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Giving moderators more permissions" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Giving moderators more permissions" tab="JavaScript" index=1 // COPY: jamesBondId="james_bond" // Add a member with moderator role await channel.addMembers([ @@ -96,7 +96,7 @@ describe(DOCS, () => { .update({ add_moderators: [ownerId] }); const enableSpy = vi.spyOn(channel, 'enableSlowMode'); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Slow mode" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Slow mode" tab="JavaScript" index=1 // enable slow mode and set cooldown to 1s await channel.enableSlowMode(1); @@ -117,7 +117,7 @@ describe(DOCS, () => { const messageId = await sendSpamMessage('Buy now!'); flaggedMessageIds.push(messageId); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Flag" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Flag" tab="JavaScript" index=1 // A user's flag on a message is recorded once: flagging it again doesn't update the reason or custom data // Flag a message const flag = await client.moderation.flagMessage(messageId); @@ -145,7 +145,7 @@ describe(DOCS, () => { flaggedMessageIds.push(messageID); const flagSpy = vi.spyOn(client.moderation, 'flagMessage'); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Reasons & custom data" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Reasons & custom data" tab="JavaScript" index=1 // A user's flag on a message is recorded once: flagging it again doesn't update the reason or custom data // flag with a reason let flag = await client.moderation.flagMessage(messageID, 'spammy_user'); @@ -188,7 +188,7 @@ describe(DOCS, () => { const client = adminClient; const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Query Flagged Messages" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Query Flagged Messages" tab="JavaScript" index=1 const response = await client.moderation.queryReviewQueue({ filter: { entity_type: 'stream:chat:v1:message' }, sort: [{ field: 'created_at', direction: -1 }], @@ -220,7 +220,7 @@ describe(DOCS, () => { it('blocks a user (Block)', async () => { const userToBlock = blockTargets[0]; - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Block" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Block" tab="JavaScript" index=1 // COPY: userToBlock="user-to-block" await client.blockUser(userToBlock); // #endregion snippet @@ -233,7 +233,7 @@ describe(DOCS, () => { const userToBlock = blockTargets[1]; const blockedUser = blockTargets[1]; - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Block User" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Block User" tab="JavaScript" index=1 // COPY: userToBlock="user-to-block" await client.blockUser(userToBlock); // #endregion snippet @@ -241,11 +241,11 @@ describe(DOCS, () => { const before = await serverClient.getBlockedUsers({ user_id: ownerId }); expect(before.blocks.map((b) => b.blocked_user_id)).toContain(blockedUser); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Unblock user" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Unblock user" tab="JavaScript" index=1 await client.unblockUser(blockedUser); // #endregion snippet - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="List of Blocked Users" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="List of Blocked Users" tab="JavaScript" index=1 const resp = await client.getBlockedUsers(); // #endregion snippet @@ -259,7 +259,7 @@ describe(DOCS, () => { const userId = blockTargets[2]; const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="JavaScript" index=1 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Server Side" tab="JavaScript" index=1 // Blocks a user on behalf of the connected user. // Must be called after client.connectUser has resolved. try { @@ -272,7 +272,7 @@ describe(DOCS, () => { const blocked = await serverClient.getBlockedUsers({ user_id: ownerId }); expect(blocked.blocks.map((b) => b.blocked_user_id)).toContain(userId); - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="JavaScript" index=2 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Server Side" tab="JavaScript" index=2 // Unblocks a user on behalf of the connected user. try { await chatClient.unblockUser(userId); @@ -286,7 +286,7 @@ describe(DOCS, () => { logSpy.mockRestore(); } - // #region snippet docs="_default/12-best_practices/02-moderation.md" heading="Server Side" tab="JavaScript" index=3 + // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Server Side" tab="JavaScript" index=3 const resp = await client.getBlockedUsers(); // #endregion snippet diff --git a/test/docs-snippets/pages/javascript/01-quick_start/01-plain_js_introduction.test.ts b/test/docs-snippets/pages/chat/javascript/01-quick_start/01-plain_js_introduction.test.ts similarity index 79% rename from test/docs-snippets/pages/javascript/01-quick_start/01-plain_js_introduction.test.ts rename to test/docs-snippets/pages/chat/javascript/01-quick_start/01-plain_js_introduction.test.ts index 0321130e79..39c40bdcb9 100644 --- a/test/docs-snippets/pages/javascript/01-quick_start/01-plain_js_introduction.test.ts +++ b/test/docs-snippets/pages/chat/javascript/01-quick_start/01-plain_js_introduction.test.ts @@ -1,10 +1,10 @@ import { afterAll, describe, expect, it, vi } from 'vitest'; -import { StreamChat } from '../../../../../src'; -import { createUserToken, 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'; +import { StreamChat } from '../../../../../../src'; +import { createUserToken, 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 PAGE = 'javascript/01-quick_start/01-plain_js_introduction.md'; @@ -28,7 +28,7 @@ describe(PAGE, () => { Reflect.set(StreamChat, '_instance', undefined); const userToken = createUserToken(userId); - // #region snippet docs="javascript/01-quick_start/01-plain_js_introduction.md" heading="Chat client" tab="JavaScript" index=1 + // #region snippet docs="chat/javascript/01-quick_start/01-plain_js_introduction.md" heading="Chat client" tab="JavaScript" index=1 // COPY: apiKey="{{ api_key }}", userId="jlahey", userToken="{{ chat_user_token }}" const client = StreamChat.getInstance(apiKey); // you can still use new StreamChat("api_key"); @@ -51,7 +51,7 @@ describe(PAGE, () => { }); it('watches a channel, sends a message and listens to events', async () => { - // #region snippet docs="javascript/01-quick_start/01-plain_js_introduction.md" heading="Channels" tab="JavaScript" index=1 + // #region snippet docs="chat/javascript/01-quick_start/01-plain_js_introduction.md" heading="Channels" tab="JavaScript" index=1 // COPY: apiKey="{{ api_key }}", channelId="travel" const client = StreamChat.getInstance(apiKey); const channel = client.channel('messaging', channelId, { @@ -67,7 +67,7 @@ describe(PAGE, () => { expect(state.channel?.custom.name).toBe('Awesome channel about traveling'); expect(channel.initialized).toBe(true); - // #region snippet docs="javascript/01-quick_start/01-plain_js_introduction.md" heading="Messages" tab="JavaScript" index=1 + // #region snippet docs="chat/javascript/01-quick_start/01-plain_js_introduction.md" heading="Messages" tab="JavaScript" index=1 const text = 'I’m mowing the air Rand, I’m mowing the air.'; const response = await channel.sendMessage({ @@ -85,7 +85,7 @@ describe(PAGE, () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - // #region snippet docs="javascript/01-quick_start/01-plain_js_introduction.md" heading="Events" tab="JavaScript" index=1 + // #region snippet docs="chat/javascript/01-quick_start/01-plain_js_introduction.md" heading="Events" tab="JavaScript" index=1 channel.on('message.new', (event) => { console.log('received a new message', event.message?.text); console.log( diff --git a/test/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts b/test/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts new file mode 100644 index 0000000000..801ed7e839 --- /dev/null +++ b/test/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts @@ -0,0 +1,115 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Channel, StreamChat } from '../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; +import { waitForAppSetting } from '../../../helpers/server'; + +describe('platform/02-identity-and-access/04-multi-tenancy.md', () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const globalUserId = uniqueId('global-reader'); + const redTeam = uniqueId('red'); + const blueTeam = uniqueId('blue'); + const redChannelId = uniqueId('red-general'); + const otherRedChannelId = uniqueId('red-other'); + const blueChannelId = uniqueId('blue-general'); + const noTeamChannelId = uniqueId('no-team'); + let client: StreamChat; + // Querying other teams' channels needs a `*-any-team` read grant: a regular team user + // gets code 70 ("N channels match your query but cannot be returned ..."), even as a + // member. The built-in `global_read_only` role has `read-channel-any-team`. + let globalClient: StreamChat; + + beforeAll(async () => { + cleanup.users.push(userId, globalUserId); + const { app } = await serverClient.getApp(); + const originalMultiTenancy = app.multi_tenant_enabled; + cleanup.add(async () => { + await serverClient.updateApp({ multi_tenant_enabled: originalMultiTenancy }); + await waitForAppSetting(serverClient, 'multi_tenant_enabled', originalMultiTenancy); + }); + await serverClient.updateApp({ multi_tenant_enabled: true }); + await waitForAppSetting(serverClient, 'multi_tenant_enabled', true); + + client = await getClientSideClient({ id: userId, teams: [redTeam] }); + globalClient = await getClientSideClient({ + id: globalUserId, + role: 'global_read_only', + teams: [redTeam], + }); + + // One channel in the user's team, one in another team and one without a team. + const channels: Array<[string, string | undefined]> = [ + [otherRedChannelId, redTeam], + [blueChannelId, blueTeam], + [noTeamChannelId, undefined], + ]; + for (const [id, team] of channels) { + cleanup.channels.push(`messaging:${id}`); + await serverClient.chat.channel('messaging', id).getOrCreate({ + data: { team, created_by_id: userId }, + }); + } + cleanup.channels.push(`messaging:${redChannelId}`); + }); + + afterAll(async () => { + await disconnectClients(client, globalClient); + await cleanup.run(); + }); + + it('creates a channel for a team', async () => { + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Creating chat channels with a team" tab="JavaScript" index=1 + // COPY: redChannelId="red-general", redTeam="red" + // Creates the channel red-general for team red + await client.channel('messaging', redChannelId, { team: redTeam }).create(); + // #endregion snippet + + const { channels } = await serverClient.chat.queryChannels({ + filter_conditions: { cid: `messaging:${redChannelId}` }, + }); + expect(channels.map(({ channel }) => channel?.team)).toEqual([redTeam]); + }); + + it('queries channels by team', async () => { + const client = globalClient; + const spy = vi.spyOn(client, 'queryChannelsAndHydrate'); + let results: Channel[][]; + try { + // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Querying chat channels" tab="JavaScript" index=1 + // query all channels from teams that user is a part of + await client.queryChannelsAndHydrate({ filter_conditions: {} }); + + // query all channels from all teams + await client.queryChannelsAndHydrate({ filter_conditions: { team: {} } }); + + // query all channels with no teams + await client.queryChannelsAndHydrate({ + filter_conditions: { team: { $eq: null } }, + }); + // #endregion snippet + results = await Promise.all(spy.mock.results.map((result) => result.value)); + } finally { + spy.mockRestore(); + } + + const [ownTeams, allTeams, noTeam] = results.map((channels) => + channels.map((channel) => channel.id), + ); + expect(ownTeams).toContain(otherRedChannelId); + expect(ownTeams).not.toContain(blueChannelId); + expect(ownTeams).not.toContain(noTeamChannelId); + expect(allTeams).toEqual( + expect.arrayContaining([otherRedChannelId, blueChannelId, noTeamChannelId]), + ); + expect(noTeam).toContain(noTeamChannelId); + expect(noTeam).not.toContain(otherRedChannelId); + expect(noTeam).not.toContain(blueChannelId); + }); +}); diff --git a/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts b/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts new file mode 100644 index 0000000000..b43145c98c --- /dev/null +++ b/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts @@ -0,0 +1,51 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import type { InternalAxiosRequestConfig } from 'axios'; +import { StreamChat } from '../../../../../src'; +import { createUserToken, getServerClient, upsertUsers } from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +describe('platform/03-events-and-notifications/01-webhooks.md', () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const apiKey = process.env.STREAM_API_KEY as string; + let client: StreamChat | undefined; + + afterAll(async () => { + await client?.disconnectUser(); + await cleanup.run(); + }); + + it('sends the x-stream-ext header with every request', async () => { + cleanup.users.push(userId); + await upsertUsers(serverClient, [{ id: userId }]); + + // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Request info" tab="JavaScript" index=1 + client = new StreamChat(apiKey, { + axiosRequestConfig: { + headers: { + 'x-stream-ext': 'device-id=123', + }, + }, + }); + // #endregion snippet + + // Webhook delivery isn't checked: the header reaching the API is enough. + const requests: InternalAxiosRequestConfig[] = []; + client.axiosInstance.interceptors.request.use((config) => { + requests.push(config); + return config; + }); + client.options.allowServerSideConnect = true; + await client.connectUser({ id: userId }, createUserToken(userId)); + await client.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [userId] } }, + }); + + expect(requests.length).toBeGreaterThan(0); + for (const request of requests) { + expect(request.headers['x-stream-ext']).toBe('device-id=123'); + } + }); +}); diff --git a/test/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts b/test/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts new file mode 100644 index 0000000000..6e5e11fdde --- /dev/null +++ b/test/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { StreamChat } from '../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +// Devices are registered on a Firebase push provider of the test app, named by +// STREAM_PUSH_PROVIDER_NAME (.env). Named providers make the app a multi-bundle config, so +// `createDevice` needs the name: without it, code 4 'push provider "" with type "firebase" not found'. +const PROVIDER = 'firebase' as const; +const PROVIDER_NAME = process.env.STREAM_PUSH_PROVIDER_NAME; +/** Appended to the names of the tests that need a provider, so a skip says why. */ +const needsProvider = PROVIDER_NAME + ? '' + : ' (skipped: STREAM_PUSH_PROVIDER_NAME is not set, see .env.example)'; + +describe('platform/03-events-and-notifications/04-push-devices.md', () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + let client: StreamChat; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + }); + + afterAll(async () => { + await disconnectClients(client); + await cleanup.run(); + }); + + it.skip('BLOCKED: registers a device (the first call is for single-provider apps; the test app is multi-bundle, so it needs push_provider_name)', async () => { + const push_provider_name = PROVIDER_NAME; + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Registering from the chat client SDKs" tab="JavaScript" index=1 + const id = '2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207'; + const push_provider = 'apn'; + + // registers the device for the connected user + await client.createDevice({ id, push_provider }); + + // if multi providers + await client.createDevice({ id, push_provider, push_provider_name }); + // #endregion snippet + + const { devices } = await client.listDevices(); + expect(devices.map((device) => device.id)).toContain(id); + }); + + it.skipIf(!PROVIDER_NAME)( + `lists the devices of the connected user${needsProvider}`, + async () => { + const deviceId = uniqueId('device'); + await client.createDevice({ + id: deviceId, + push_provider: PROVIDER, + push_provider_name: PROVIDER_NAME, + }); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Listing from the chat client SDKs" tab="JavaScript" index=1 + // lists the devices of the connected user + const { devices } = await client.listDevices(); + // #endregion snippet + + expect(devices).toEqual([ + expect.objectContaining({ + id: deviceId, + push_provider: PROVIDER, + push_provider_name: PROVIDER_NAME, + user_id: userId, + }), + ]); + await client.deleteDevice({ id: deviceId }); + }, + ); + + it.skipIf(!PROVIDER_NAME)(`removes a device${needsProvider}`, async () => { + await client.createDevice({ + id: '2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207', + push_provider: PROVIDER, + push_provider_name: PROVIDER_NAME, + }); + expect((await client.listDevices()).devices).toHaveLength(1); + + // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Removing from the chat client SDKs" tab="JavaScript" index=1 + const id = '2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207'; + + // removes the device from the connected user + await client.deleteDevice({ id }); + // #endregion snippet + + const { devices } = await client.listDevices(); + expect(devices).toEqual([]); + }); +}); diff --git a/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts b/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts new file mode 100644 index 0000000000..17dec6e8e4 --- /dev/null +++ b/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts @@ -0,0 +1,323 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { nsToMs, StreamChat } from '../../../../../src'; +import type { TimestampNS, UpsertPushPreferencesResponse } from '../../../../../src'; +import { + createUserToken, + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../helpers/clients'; +import { Cleanup } from '../../../helpers/cleanup'; +import { uniqueId } from '../../../helpers/ids'; + +// Push delivery isn't checked: reading the saved preferences back is enough. User-level +// preferences come back in the connect response (`me.push_preferences`), channel-member +// preferences on `channel.push_preferences` after `queryChannelsAndHydrate`. +describe('platform/03-events-and-notifications/05-push-preferences.md', () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const general = `messaging:${uniqueId('general')}`; + const announcements = `messaging:${uniqueId('announcements')}`; + const urgentAlerts = `messaging:${uniqueId('urgent-alerts')}`; + const socialChat = `messaging:${uniqueId('social-chat')}`; + let client: StreamChat; + + /** The user's own push preferences, as a fresh connection of the same user sees them. */ + const getUserPreferences = async () => { + const reader = new StreamChat(process.env.STREAM_API_KEY as string, { + allowServerSideConnect: true, + }); + try { + const response = await reader.connectUser({ id: userId }, createUserToken(userId)); + return response?.me?.push_preferences; + } finally { + await reader.disconnectUser(); + } + }; + + /** Channel-member push preferences of the connected user, by cid. */ + const getChannelPreferences = async () => { + const channels = await client.queryChannelsAndHydrate({ + filter_conditions: { + cid: { $in: [general, announcements, urgentAlerts, socialChat] }, + }, + }); + return Object.fromEntries( + channels.map((channel) => [channel.cid, channel.push_preferences]), + ); + }; + + /** `disabled_until` comes back as a unix-nanosecond timestamp. */ + const isAbout = (actual: TimestampNS | undefined, expected: Date) => + actual !== undefined && Math.abs(nsToMs(actual) - expected.getTime()) < 1000; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + // Channel preferences need an existing channel ("channel with CID ... does not exist"). + for (const cid of [general, announcements, urgentAlerts, socialChat]) { + cleanup.channels.push(cid); + const [type, id] = cid.split(':'); + await serverClient.chat.channel(type, id).getOrCreate({ + data: { created_by_id: userId, members: [{ user_id: userId }] }, + }); + } + }); + + afterAll(async () => { + await disconnectClients(client); + await cleanup.run(); + }); + + it('sets user-level preferences', async () => { + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="User-Level Preferences" tab="JavaScript" index=1 + // Set user-level push preferences for the current user + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'mentions', + }, + ], + }); + // #endregion snippet + + expect((await getUserPreferences())?.chat_level).toBe('mentions'); + }); + + it('sets channel-member preferences', async () => { + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Channel-Member Preferences" tab="JavaScript" index=1 + // COPY: general="messaging:general", announcements="messaging:announcements" + // Set per-channel preferences for the current user + await client.updatePushNotificationPreferences({ + preferences: [ + { + channel_cid: general, + chat_level: 'none', + }, + { + channel_cid: announcements, + chat_level: 'all', + }, + ], + }); + // #endregion snippet + + const preferences = await getChannelPreferences(); + expect(preferences[general]?.chat_level).toBe('none'); + expect(preferences[announcements]?.chat_level).toBe('all'); + }); + + it('sets preferences client-side', async () => { + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Client-Side Usage" tab="JavaScript" index=1 + // COPY: general="messaging:general" + // Client-side - can only update current user's preferences + await client.updatePushNotificationPreferences({ + preferences: [ + { + // user_id is optional and will be automatically set to current user + chat_level: 'mentions', + }, + { + // Set preferences for a specific channel + channel_cid: general, + chat_level: 'all', + }, + ], + }); + // #endregion snippet + + expect((await getUserPreferences())?.chat_level).toBe('mentions'); + expect((await getChannelPreferences())[general]?.chat_level).toBe('all'); + }); + + it('creates a "Do Not Disturb" mode', async () => { + const spy = vi.spyOn(client, 'updatePushNotificationPreferences'); + let responses: UpsertPushPreferencesResponse[]; + try { + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="1: Creating a \"Do Not Disturb\" Mode" tab="JavaScript" index=1 + // Disable all push notifications + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'none', + }, + ], + }); + + // Later, re-enable all push notifications + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'all', + }, + ], + }); + // #endregion snippet + responses = await Promise.all(spy.mock.results.map((result) => result.value)); + } finally { + spy.mockRestore(); + } + + expect( + responses.map((response) => response.user_preferences[userId]?.chat_level), + ).toEqual(['none', 'all']); + expect((await getUserPreferences())?.chat_level).toBe('all'); + }); + + it('sets channel-specific notification settings', async () => { + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="2: Channel-Specific Notification Settings" tab="JavaScript" index=1 + // COPY: general="messaging:general", urgentAlerts="messaging:urgent-alerts", socialChat="messaging:social-chat" + // Set different preferences for different channels + await client.updatePushNotificationPreferences({ + preferences: [ + { + channel_cid: general, + chat_level: 'mentions', // Default: mentions only + }, + { + channel_cid: urgentAlerts, + chat_level: 'all', // Always notify for urgent alerts + }, + { + channel_cid: socialChat, + chat_level: 'none', // Never notify for social chat + }, + ], + }); + // #endregion snippet + + const preferences = await getChannelPreferences(); + expect(preferences[general]?.chat_level).toBe('mentions'); + expect(preferences[urgentAlerts]?.chat_level).toBe('all'); + expect(preferences[socialChat]?.chat_level).toBe('none'); + }); + + it('temporarily disables push notifications', async () => { + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="3: Temporarily Disabling Push Notifications" tab="JavaScript" index=1 + // COPY: general="messaging:general" + // Disable push notifications for 2 hours for the current user + const twoHoursFromNow = new Date(Date.now() + 2 * 60 * 60 * 1000); + + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'all', + disabled_until: twoHoursFromNow, + }, + ], + }); + + // Disable push notifications for a specific channel until tomorrow + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(9, 0, 0, 0); // 9 AM tomorrow + + await client.updatePushNotificationPreferences({ + preferences: [ + { + channel_cid: general, + chat_level: 'all', + disabled_until: tomorrow, + }, + ], + }); + // #endregion snippet + + const userPreferences = await getUserPreferences(); + expect(userPreferences?.chat_level).toBe('all'); + expect(isAbout(userPreferences?.disabled_until, twoHoursFromNow)).toBe(true); + const channelPreferences = (await getChannelPreferences())[general]; + expect(channelPreferences?.chat_level).toBe('all'); + expect(isAbout(channelPreferences?.disabled_until, tomorrow)).toBe(true); + }); + + it('sets granular preferences', async () => { + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Setting granular preferences" tab="JavaScript" index=1 + // COPY: general="messaging:general" + // User-level: only receive pushes for direct mentions and thread replies + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_preferences: { + default_preference: 'none', + direct_mentions: 'all', + thread_replies: 'all', + }, + }, + ], + }); + + // Channel-member level: mute @channel and @here in a noisy channel + await client.updatePushNotificationPreferences({ + preferences: [ + { + channel_cid: general, + chat_preferences: { + default_preference: 'all', + channel_mentions: 'none', + here_mentions: 'none', + }, + }, + ], + }); + // #endregion snippet + + const userPreferences = await getUserPreferences(); + expect(userPreferences?.chat_preferences).toEqual({ + default_preference: 'none', + direct_mentions: 'all', + thread_replies: 'all', + }); + // `chat_preferences` clears `chat_level`. + expect(userPreferences?.chat_level).toBeUndefined(); + expect((await getChannelPreferences())[general]?.chat_preferences).toEqual({ + default_preference: 'all', + channel_mentions: 'none', + here_mentions: 'none', + }); + }); + + it('switches from chat_preferences back to chat_level', async () => { + await client.updatePushNotificationPreferences({ + preferences: [{ chat_preferences: { default_preference: 'none' } }], + }); + + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Switching between chat_level and chat_preferences" tab="JavaScript" index=1 + // Switch from granular preferences back to a simple level + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'all_mentions', + }, + ], + }); + // #endregion snippet + + const userPreferences = await getUserPreferences(); + expect(userPreferences?.chat_level).toBe('all_mentions'); + expect(userPreferences?.chat_preferences).toBeUndefined(); + }); + + it('sets call push preferences', async () => { + // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Setting Call Push Preferences" tab="JavaScript" index=1 + // COPY: userId="user-1" + // Set call-level preferences with temporary disabling + const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000); + + await client.updatePushNotificationPreferences({ + preferences: [ + { + user_id: userId, + call_level: 'all', + disabled_until: oneHourFromNow, + }, + ], + }); + // #endregion snippet + + const userPreferences = await getUserPreferences(); + expect(userPreferences?.call_level).toBe('all'); + expect(isAbout(userPreferences?.disabled_until, oneHourFromNow)).toBe(true); + }); +}); diff --git a/test/docs-snippets/sync/docs-sync.test.ts b/test/docs-snippets/sync/docs-sync.test.ts index 237330da7d..971e08ebbf 100644 --- a/test/docs-snippets/sync/docs-sync.test.ts +++ b/test/docs-snippets/sync/docs-sync.test.ts @@ -4,12 +4,13 @@ import { describe, expect, it } from 'vitest'; import { ALLOWED_JS_LABELS, applyCopy, - DOCS_CHAT_DIR, + DOCS_DIR, EXCLUDED_PAGES, fenceId, findSidebarPages, findTestFiles, formatForDocs, + isExcludedFence, NODE_TAB, normalize, parseDocsFences, @@ -20,15 +21,17 @@ import type { DocsFence, TestRegion } from './parse'; // `yarn test-docs-sync`, in this order: // 1. Every JS fence (except `Node.js` and comment-only fences, like the "server-side -// only" placeholders) on the pages of the chat JavaScript sidebar has a test region. +// only" placeholders) on the pages of the tested sidebars (chat JavaScript, platform) +// has a test region. // 2. Every JS fence on those pages is labelled `JavaScript` or `Node.js`. // 3. Every marked test region points at an existing fence and matches it (after // applying COPY values and formatting both sides the same way). -// Pages in `EXCLUDED_PAGES` (parse.ts) are skipped. +// Pages in `EXCLUDED_PAGES` and fences in `EXCLUDED_SECTIONS` (parse.ts) are skipped by +// 1. and 2. // DOCS_SYNC_WRITE= overwrite that page's fences with the test code instead of // comparing (page path as in the markers, e.g. -// _default/04-messages/01-send_message.md; `1` = all pages) -// DOCS_CHAT_DIR=... path to getstream.io/content/docs/chat (default: sibling checkout) +// chat/_default/04-messages/01-send_message.md; `1` = all pages) +// DOCS_DIR=... path to getstream.io/content/docs (default: sibling checkout) const writeTarget = process.env.DOCS_SYNC_WRITE; const shouldWrite = (docs: string) => writeTarget === '1' || writeTarget === docs; @@ -46,16 +49,19 @@ it('collects the test regions', () => { // Without a docs checkout there is nothing to compare against: skip (vitest still runs // the describe body to collect tests, so it must not read the docs either). -const hasDocs = fs.existsSync(DOCS_CHAT_DIR); +const hasDocs = fs.existsSync(DOCS_DIR); -describe.skipIf(!hasDocs)(`docs sync (${DOCS_CHAT_DIR})`, () => { +describe.skipIf(!hasDocs)(`docs sync (${DOCS_DIR})`, () => { // Pages with tests count even if they are no longer in the sidebar. const pages = [...new Set([...(hasDocs ? findSidebarPages() : []), ...regionPages])] .filter((docs) => !(docs in EXCLUDED_PAGES)) .sort(); const pageFences = (hasDocs ? pages : []).map((docs) => { const { file, markdown } = readDocsPage(docs); - return { docs, file, fences: parseDocsFences(docs, markdown) }; + const fences = parseDocsFences(docs, markdown).filter( + (fence) => !isExcludedFence(fence), + ); + return { docs, file, fences }; }); const where = (file: string, fence: DocsFence) => `${fenceId(fence)} (${file}:${fence.line})`; @@ -72,7 +78,7 @@ describe.skipIf(!hasDocs)(`docs sync (${DOCS_CHAT_DIR})`, () => { ); 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 section to EXCLUDED_SECTIONS) in sync/parse.ts with a reason', ).toEqual([]); }); @@ -84,7 +90,7 @@ describe.skipIf(!hasDocs)(`docs sync (${DOCS_CHAT_DIR})`, () => { ); expect( mislabelled, - `JS fences need label="${ALLOWED_JS_LABELS.join('" or label="')}" (client-side code or @stream-io/node-sdk code)`, + `JS fences need label="${ALLOWED_JS_LABELS.join('" or label="')}" (client-side code, or server-side Node.js code with or without @stream-io/node-sdk)`, ).toEqual([]); }); @@ -127,7 +133,7 @@ describe.skipIf(!hasDocs)(`docs sync (${DOCS_CHAT_DIR})`, () => { /** Replaces one fence body; re-reads the file so several writes to one page compose. */ const writeFence = (docsFile: string, fence: DocsFence, code: string) => { - const docs = path.relative(DOCS_CHAT_DIR, docsFile); + const docs = path.relative(DOCS_DIR, docsFile); const current = parseDocsFences(docs, fs.readFileSync(docsFile, 'utf8')).find( (candidate) => fenceId(candidate) === fenceId(fence), ); diff --git a/test/docs-snippets/sync/parse.ts b/test/docs-snippets/sync/parse.ts index 006755f98d..7adc96c223 100644 --- a/test/docs-snippets/sync/parse.ts +++ b/test/docs-snippets/sync/parse.ts @@ -3,15 +3,14 @@ import path from 'node:path'; import { format } from 'prettier'; import type { Options } from 'prettier'; -/** `content/docs/chat` in a getstream.io checkout (override with DOCS_CHAT_DIR). */ -export const DOCS_CHAT_DIR = path.resolve( - process.env.DOCS_CHAT_DIR ?? - path.join(__dirname, '../../../../getstream.io/content/docs/chat'), +/** `content/docs` in a getstream.io checkout (override with DOCS_DIR). */ +export const DOCS_DIR = path.resolve( + process.env.DOCS_DIR ?? path.join(__dirname, '../../../../getstream.io/content/docs'), ); /** Identifies one code fence on a docs page. */ export type FenceKey = { - /** Page path relative to `content/docs/chat`, e.g. `_default/04-messages/01-send_message.md`. */ + /** Page path relative to `content/docs`, e.g. `chat/_default/04-messages/01-send_message.md`. */ docs: string; /** Text of the nearest heading above the fence (`""` before the first heading). */ heading: string; @@ -26,6 +25,8 @@ export type DocsFence = FenceKey & { lang: string; /** Fence body, de-indented. */ code: string; + /** Texts of the headings the fence is nested under, outermost first (ends with `heading`). */ + headingPath: string[]; /** 1-based line of the opening ``` line. */ line: number; /** 0-based line range of the body in the file (end exclusive), plus the fence indent. */ @@ -51,23 +52,72 @@ export const fenceId = (key: FenceKey) => const JS_LANGS = new Set(['js', 'javascript', 'ts', 'typescript']); /** The tested tab. */ export const JS_TAB = 'JavaScript'; -/** Out of scope (server-side `@stream-io/node-sdk` code). */ +/** + * Out of scope: server-side code, both `@stream-io/node-sdk` code and Node.js reference + * implementations without an SDK (raw HTTP, AWS SDK, ...). Neither tested nor changed. + */ export const NODE_TAB = 'Node.js'; /** Every JS fence must carry one of these labels. */ export const ALLOWED_JS_LABELS = [JS_TAB, NODE_TAB]; -/** JavaScript sidebar of the chat docs: the pages whose `JavaScript` fences are tested. */ -const SIDEBAR_FILE = '[chat][javascript].json'; +/** + * Sidebars whose pages' `JavaScript` fences are tested: sidebar file in + * `content/docs/_sidebars`, and the prefix of the page paths taken from it. + */ +const SIDEBARS = [ + { file: '[chat][javascript].json', prefix: 'chat/' }, + { file: '[platform][default].json', prefix: 'platform/' }, +]; /** - * Pages in the JavaScript sidebar that are deliberately not validated (page path - * relative to `content/docs/chat`, reason). + * Sidebar pages that are deliberately not validated (page path relative to + * `content/docs`, reason). */ export const EXCLUDED_PAGES: Record = { - 'javascript/11-debugging_and_cli/10-upgrading_to_v9.md': + 'chat/javascript/11-debugging_and_cli/10-upgrading_to_v9.md': 'React/TSX type examples, no runnable JS', }; +const FEEDS = + 'Feeds client SDK (@stream-io/feeds-client and its React hooks), not stream-chat-js'; + +/** + * Sections of a page that are not validated: every fence under one of these headings + * (at any level above the fence) is skipped by the coverage and label checks. + */ +export const EXCLUDED_SECTIONS: Record = { + 'platform/02-identity-and-access/02-users.md': { + headings: ['Guest users', 'Anonymous users'], + reason: FEEDS, + }, + 'platform/03-events-and-notifications/02-push-notifications.md': { + headings: [ + 'Handling Push Notifications on the Foreground', + 'Direct Push Notifications', + 'Notification Feed Push Notifications', + ], + reason: `${FEEDS}; React Native notifee example`, + }, + 'platform/03-events-and-notifications/04-push-devices.md': { + headings: [ + 'Multi-Bundle Device Registration', + 'Listing from the feeds client SDKs', + 'Removing from the feeds client SDKs', + ], + reason: FEEDS, + }, + 'platform/03-events-and-notifications/05-push-preferences.md': { + headings: ['Feeds Push Preferences'], + reason: FEEDS, + }, +}; + +/** True when the fence is in one of its page's `EXCLUDED_SECTIONS`. */ +export const isExcludedFence = (fence: DocsFence) => + fence.headingPath.some((heading) => + EXCLUDED_SECTIONS[fence.docs]?.headings.includes(heading), + ); + const dedent = (lines: string[]) => { const indents = lines .filter((line) => line.trim()) @@ -97,11 +147,16 @@ export const parseDocsFences = (docs: string, markdown: string): DocsFence[] => const fences: DocsFence[] = []; const counts = new Map(); let heading = ''; + // Open headings by level: `headings[level - 1]`. + let headings: string[] = []; for (let i = 0; i < lines.length; i++) { const open = /^(\s*)(`{3,})(\S*)(.*)$/.exec(lines[i]); if (!open) { - const h = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(lines[i]); - if (h) heading = h[1]; + const h = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(lines[i]); + if (h) { + heading = h[2]; + headings = [...headings.slice(0, h[1].length - 1), heading]; + } continue; } const [, indent, ticks, lang, rest] = open; @@ -117,6 +172,7 @@ export const parseDocsFences = (docs: string, markdown: string): DocsFence[] => fences.push({ docs, heading, + headingPath: headings.filter(Boolean), tab, index, lang, @@ -262,26 +318,29 @@ export const formatForDocs = async (code: string, markdownFile: string) => { type SidebarItem = { markdown?: string; children?: SidebarItem[] }; /** - * Pages of the chat JavaScript sidebar (paths relative to `content/docs/chat`), minus + * Pages of the tested sidebars (`SIDEBARS`, paths relative to `content/docs`), minus * `EXCLUDED_PAGES`. */ export const findSidebarPages = () => { - const file = path.join(DOCS_CHAT_DIR, '..', '_sidebars', SIDEBAR_FILE); - const { items } = JSON.parse(fs.readFileSync(file, 'utf8')) as { items: SidebarItem[] }; const pages = new Set(); - const walk = (entries: SidebarItem[] = []) => { - for (const entry of entries) { - if (entry.markdown?.startsWith('chat/')) - pages.add(entry.markdown.slice('chat/'.length)); - walk(entry.children); - } - }; - walk(items); + for (const sidebar of SIDEBARS) { + const file = path.join(DOCS_DIR, '_sidebars', sidebar.file); + const { items } = JSON.parse(fs.readFileSync(file, 'utf8')) as { + items: SidebarItem[]; + }; + const walk = (entries: SidebarItem[] = []) => { + for (const entry of entries) { + if (entry.markdown?.startsWith(sidebar.prefix)) pages.add(entry.markdown); + walk(entry.children); + } + }; + walk(items); + } return [...pages].filter((page) => !(page in EXCLUDED_PAGES)).sort(); }; export const readDocsPage = (docs: string) => { - const file = path.join(DOCS_CHAT_DIR, docs); + const file = path.join(DOCS_DIR, docs); return { file, markdown: fs.readFileSync(file, 'utf8') }; }; diff --git a/test/types/team-filter.ts b/test/types/team-filter.ts new file mode 100644 index 0000000000..04f383051d --- /dev/null +++ b/test/types/team-filter.ts @@ -0,0 +1,15 @@ +/** + * Compile-time contract for the channel `team` filter. Type-checked, never executed. + * + * With multi-tenancy, an empty `team` filter queries across all teams, so `team: {}` must be + * accepted alongside the usual operators. Other keys must keep rejecting an empty object. + */ +import type { ChannelFilters } from 'stream-chat'; + +export const allTeams: ChannelFilters = { team: {} }; +export const noTeam: ChannelFilters = { team: { $eq: null } }; +export const oneTeam: ChannelFilters = { team: 'red' }; +export const someTeams: ChannelFilters = { team: { $in: ['red', 'blue'] } }; + +// @ts-expect-error -- only `team` gives `{}` a meaning +export const emptyType: ChannelFilters = { type: {} }; From 0c6a90d866d2c80d7ab0fc1c9b9c5f5fbac35305 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 15 Oct 2026 10:51:12 -0500 Subject: [PATCH 04/10] chore: adjust to latest client version --- test/docs-snippets/helpers/clients.ts | 2 +- .../01-client_tokens_and_authentication.test.ts | 6 +++--- .../chat/02-init_and_users/02-init_and_users.test.ts | 2 +- .../chat/02-init_and_users/05-authless_users.test.ts | 2 +- .../07-channel_management/10-muting.test.ts | 2 +- .../pages/chat/04-messages/03-threads.test.ts | 2 +- .../pages/chat/04-messages/10-message_receipts.test.ts | 4 +++- .../pages/chat/05-features/03-unread.test.ts | 2 +- .../pages/chat/05-features/05-presence_format.test.ts | 2 +- .../pages/chat/05-features/09-translation.test.ts | 2 +- .../03-events-and-notifications/01-webhooks.test.ts | 10 ++++++---- .../05-push-preferences.test.ts | 2 +- 12 files changed, 21 insertions(+), 17 deletions(-) diff --git a/test/docs-snippets/helpers/clients.ts b/test/docs-snippets/helpers/clients.ts index c8d1a4cbef..2463b1e6ce 100644 --- a/test/docs-snippets/helpers/clients.ts +++ b/test/docs-snippets/helpers/clients.ts @@ -67,7 +67,7 @@ export const upsertUsers = (serverClient: StreamClient, users: TestUser[]) => export const getClientSideClient = async (user: TestUser): Promise => { await upsertUsers(getServerClient(), [user]); // `new StreamChat` instead of `getInstance` so several users can be connected in one file. - const client = new StreamChat(apiKey(), { allowServerSideConnect: true }); + const client = new StreamChat(apiKey(), { client: { allowServerSideConnect: true } }); await client.connectUser(user, createUserToken(user.id)); return client; }; diff --git a/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts index a8f93f5f0e..cdd5c593ec 100644 --- a/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts @@ -12,7 +12,7 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () cleanup.users.push(userId); // A client initialized with the API key alone, the way the docs set it up. const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); let originalDisableAuthChecks = false; @@ -22,7 +22,7 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () const deadline = Date.now() + 75000; for (;;) { const probe = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); try { await probe.connectUser({ id: userId }, client.devToken(userId)); @@ -52,7 +52,7 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () if (originalDisableAuthChecks) skip('auth checks are already disabled on the app'); const devClient = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); await expect( devClient.connectUser({ id: userId }, client.devToken(userId)), diff --git a/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts index 77d9cdc107..9064dccf95 100644 --- a/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts @@ -12,7 +12,7 @@ const apiKey = process.env.STREAM_API_KEY as string; import { StreamChat } from 'stream-chat'; const chatClient = StreamChat.getInstance(apiKey, { - axiosRequestConfig: { timeout: 6000 }, + client: { axiosRequestConfig: { timeout: 6000 } }, }); // #endregion snippet diff --git a/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts index cbb36a2b89..4285425fa3 100644 --- a/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts @@ -13,7 +13,7 @@ describe('_default/02-init_and_users/05-authless_users.md', () => { // A fresh client without a user, the way a front end creates it before anyone logs in. const newClient = () => { - const client = new StreamChat(apiKey, { allowServerSideConnect: true }); + const client = new StreamChat(apiKey, { client: { allowServerSideConnect: true } }); clients.push(client); return client; }; diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts index 06a226803c..ff9256b250 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts @@ -21,7 +21,7 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { const user: TestUser = { id: userId }; const token = createUserToken(userId); const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); beforeAll(async () => { diff --git a/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts b/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts index 148ebea9d0..7f1d548682 100644 --- a/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts @@ -252,7 +252,7 @@ describe('_default/04-messages/03-threads.md', () => { const readerId = otherId; const token = createUserToken(readerId); const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); try { diff --git a/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts b/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts index 5e462c135f..904e1e78cb 100644 --- a/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts @@ -32,7 +32,9 @@ describe('_default/04-messages/10-message_receipts.md', () => { const clients: StreamChat[] = []; const newChatClient = () => { - const chatClient = new StreamChat(apiKey, { allowServerSideConnect: true }); + const chatClient = new StreamChat(apiKey, { + client: { allowServerSideConnect: true }, + }); clients.push(chatClient); return chatClient; }; diff --git a/test/docs-snippets/pages/chat/05-features/03-unread.test.ts b/test/docs-snippets/pages/chat/05-features/03-unread.test.ts index 2c2fee6ac8..69971a3b5b 100644 --- a/test/docs-snippets/pages/chat/05-features/03-unread.test.ts +++ b/test/docs-snippets/pages/chat/05-features/03-unread.test.ts @@ -19,7 +19,7 @@ describe('_default/05-features/03-unread.md', () => { const token = createUserToken(userId); // Not connected yet: the first snippet connects it. const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); let firstMessageId = ''; diff --git a/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts b/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts index 9d5a94fa11..b86ec0c1eb 100644 --- a/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts +++ b/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts @@ -154,7 +154,7 @@ describe('_default/05-features/05-presence_format.md', () => { await serverClient.upsertUsers([{ id: userId }]); const token = createUserToken(userId); const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); extraClients.push(client); const connectSpy: { reply?: Awaited> } = {}; diff --git a/test/docs-snippets/pages/chat/05-features/09-translation.test.ts b/test/docs-snippets/pages/chat/05-features/09-translation.test.ts index daf67443f2..ecc0ed3526 100644 --- a/test/docs-snippets/pages/chat/05-features/09-translation.test.ts +++ b/test/docs-snippets/pages/chat/05-features/09-translation.test.ts @@ -33,7 +33,7 @@ describe(DOCS, () => { let spanishClient: StreamChat; let channel: Channel; const languageClient = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); /** Sends a poll from the English user in an auto-translated channel with a Spanish member. */ diff --git a/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts b/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts index b43145c98c..3f4236c07a 100644 --- a/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts +++ b/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts @@ -23,9 +23,11 @@ describe('platform/03-events-and-notifications/01-webhooks.md', () => { // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Request info" tab="JavaScript" index=1 client = new StreamChat(apiKey, { - axiosRequestConfig: { - headers: { - 'x-stream-ext': 'device-id=123', + client: { + axiosRequestConfig: { + headers: { + 'x-stream-ext': 'device-id=123', + }, }, }, }); @@ -37,7 +39,7 @@ describe('platform/03-events-and-notifications/01-webhooks.md', () => { requests.push(config); return config; }); - client.options.allowServerSideConnect = true; + client.config.setConfig('client', { allowServerSideConnect: true }); await client.connectUser({ id: userId }, createUserToken(userId)); await client.queryChannelsAndHydrate({ filter_conditions: { members: { $in: [userId] } }, diff --git a/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts b/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts index 17dec6e8e4..2872e8a367 100644 --- a/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts +++ b/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts @@ -26,7 +26,7 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { /** The user's own push preferences, as a fresh connection of the same user sees them. */ const getUserPreferences = async () => { const reader = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); try { const response = await reader.connectUser({ id: userId }, createUserToken(userId)); From 6aa1403d4cb6e36975d22e2c295b6c954269d701 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 15 Oct 2026 11:44:41 -0500 Subject: [PATCH 05/10] chore: cleanup docs, add sync skill --- .claude/skills/docs-sync/SKILL.md | 148 ++++++++++++++ .../docs-snippets/{learnings.md => AGENTS.md} | 79 +++++--- test/docs-snippets/README.md | 25 +-- .../docs-snippets-agent-prompt.md | 14 +- test/docs-snippets/docs-snippets-todo.md | 186 ------------------ 5 files changed, 212 insertions(+), 240 deletions(-) create mode 100644 .claude/skills/docs-sync/SKILL.md rename test/docs-snippets/{learnings.md => AGENTS.md} (86%) diff --git a/.claude/skills/docs-sync/SKILL.md b/.claude/skills/docs-sync/SKILL.md new file mode 100644 index 0000000000..8e0b20369f --- /dev/null +++ b/.claude/skills/docs-sync/SKILL.md @@ -0,0 +1,148 @@ +--- +name: docs-sync +description: Sync the stream-chat-js docs snippet tests (test/docs-snippets) with a getstream.io docs checkout. Asks for the docs repo, runs `yarn test-docs-sync` against it, brings the tests up to date with docs changes (docs win), adds tests for new snippets, and writes fixes for broken snippets back to the docs. Use when the user asks to sync, check or update the docs snippets or docs snippet tests. +argument-hint: '[path to the getstream.io checkout or its content/docs folder]' +--- + +# Docs sync + +The tests in `test/docs-snippets/` run the client-side `JavaScript` snippets of the Stream Chat docs against a real Stream app. 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 `src/`, runtime error that isn't caused by test setup, wrong response shape), and then write the fix back from the test. + +All paths below are relative to the stream-chat-js repo root unless they start with `$DOCS_DIR`. + +## 1. Find the docs folder + +If the user passed a path as the argument, use it. Otherwise ask for it with AskUserQuestion: "Where is the getstream.io docs checkout?", offering `../getstream.io` (the default sibling checkout) as the first option, and accepting any path through "Other". + +Resolve it to the `content/docs` folder: + +- A repo root (has `content/docs/_sidebars/`): use `/content/docs`. +- A path that already ends in `content/docs` (has `_sidebars/`): use it as is. +- A bare repo name (e.g. `getstream.io`): try it as a sibling of this repo (`../`). +- Anything else, or a folder without `_sidebars/`: tell the user what was checked and ask again. Never guess a path from another machine. + +Make it absolute and call it `$DOCS_DIR`. Pass it as `DOCS_DIR=$DOCS_DIR` on **every** `yarn test-docs-sync` call: without it, the sync check falls back to the sibling checkout and, if that doesn't exist, silently skips the docs comparison. + +Then show the user, before changing anything: + +- the docs repo's current branch and `git status --short` (run git in `$DOCS_DIR`), +- the stream-chat-js branch and `git status --short`. + +If either repo has uncommitted changes, say so: your edits will mix with them. Don't stash, commit or switch branches in either repo. + +## 2. Read the conventions + +Before editing tests, read: + +1. `test/docs-snippets/AGENTS.md`: test conventions and API quirks. Follow it. +2. `test/docs-snippets/README.md`: layout, commands and the snippet-marker format (fence key, COPY, `DOCS_SYNC_WRITE`). +3. `$DOCS_DIR/AGENTS.md`, if it exists: the docs writing rules (e.g. no em-dashes). + +Live tests need `test/docs-snippets/.env` (copy `.env.example`, fill in `STREAM_API_KEY` / `STREAM_API_SECRET` of the docs test app). If it is missing or empty, ask the user for it. Without it you can still sync, typecheck and lint, but you can't tell whether a changed snippet works: say so in the report, and don't change any docs snippet in that case. + +## 3. Run the sync check + +```sh +DOCS_DIR=$DOCS_DIR yarn test-docs-sync +``` + +It runs without network and reports four kinds of failure. Collect them all, grouped by docs page, before fixing anything. + +| Failure | Meaning | +| --- | --- | +| `snippets without a test region` | A `JavaScript` fence on a tested sidebar page (chat JavaScript, platform) has no region: a new snippet, a new page, or a fence whose key changed (see the next row). | +| `JS fences need label=...` | A JS fence is unlabelled or has another label. | +| `no such fence` | A region's key (`docs`, `heading`, `tab`, `index`) matches no fence: the fence was removed, moved, or its heading was renamed. The failure lists the page's fence keys. | +| ` differs from :` | The region's code and the fence's code differ. | + +**Check that the docs branch matches the SDK first.** Because the docs win, syncing against docs written for another stream-chat-js major (e.g. v9 docs against the v10 tests on a `release-v10` based branch) would rewrite working tests to old API code. If many regions across many pages differ, or the differences look like an API change rather than a docs edit (renamed methods, different argument shapes), stop: show the user both branches and a few example differences, and ask whether this is the right docs checkout before changing anything. + +A renamed heading or a reordered fence shows up twice: as `no such fence` for the old key and as a missing region for the new one. Pair them up by code before treating either as a new or removed snippet. + +## 4. Fix each failure + +Work one docs page at a time. + +### Mislabelled fence + +Label it `JavaScript` for client code, `Node.js` for server-side code (with or without `@stream-io/node-sdk`). `Node.js` fences are out of scope: never test, add or change them otherwise. + +### Region differs from its fence (docs changed) + +1. Replace the region's code with the fence's code. Keep the region's COPY line: where the docs use a fixed value that a COPY variable stands for (`channelId="general"`), keep the variable in the region. If the docs changed that literal, update the COPY value. If the docs introduced a new fixed value that would clash with other tests (a fixed user or channel id), add a COPY variable from `uniqueId(...)` for it. +2. Adjust the setup before the region and the assertions after it to the new code (new variables, a different response shape, a new call to assert). +3. Run the page's test: `yarn test-docs `, then `yarn types-docs`. +4. If it passes, done. If it fails, decide why: + - **Test setup** (missing user, permission, channel config, eventual consistency): fix the setup, not the region. + - **SDK bug or wrong type in `src/`** while the snippet is correct: make the smallest fix in `src/` (add or update a unit test under `test/unit/` for runtime fixes), and list it in the report. `yarn types`, `yarn lint` and `yarn test` must still pass. + - **The docs snippet is wrong**: fix it as described in "Broken docs snippet" below. + - **Not sure** whether the snippet or the setup is wrong: don't touch the docs. Leave the test failing or put the snippet in `it.skip('BLOCKED: ', ...)` and report it as an open question. + +### Marker points at no fence + +- **Heading renamed or fence moved/reordered**: update the marker's `heading` / `tab` / `index` to the key the failure lists (it matches the fence with the same code). +- **Fence removed from the docs**: remove the region together with its `it` (or the part of the `it` that only exists for it) and any setup only it used. If a whole page was removed or left the sidebar, remove its test file. + +### 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` → `test/docs-snippets/pages/chat/04-messages/01-send_message.test.ts`. Follow `test/docs-snippets/AGENTS.md` (isolation with `uniqueId`, `Cleanup`, one `it` per snippet, basic `expect`s after each region). In short: + +- Run the snippet with a user token (`getClientSideClient(user)`). Use `getServerClient()` (a `@stream-io/node-sdk` client) only for setup, cleanup and assertions a client can't make. +- 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 (e.g. "This is a server-side only feature...") need no region. +- A snippet the test app can't run goes in `it.skip('BLOCKED: ', ...)` so it is still typechecked; report the exact API error. +- **Server-only code in a `JavaScript` fence** (fails with a user token, e.g. 403 code 17 or "only allowed when using server side auth"): remove that fence, or just the server-only part, from the docs. Don't add a `Node.js` tab for it. To decide whether an endpoint or an acting-user field (`user_id`, `created_by_id`, ...) is client-side, check the client-side OpenAPI spec (`chat-clientside-api.json` in https://github.com/GetStream/protocol/tree/main/openapi/v2): an endpoint or field missing from it is server-only. Report every removal with the error. +- A fence from another product (Feeds, `@stream-io/feeds-client`, React / React Native tabs on platform pages) isn't tested: report the page and heading so it can be added to `EXCLUDED_SECTIONS` in `test/docs-snippets/sync/parse.ts`. Don't edit `parse.ts` without asking the user. + +### Broken docs snippet + +Only when the docs snippet itself doesn't work (wrong method name or signature, a type error against `src/`, a runtime API error not caused by setup): + +1. Make the smallest fix in the test region. +2. Write it to the docs: `DOCS_DIR=$DOCS_DIR DOCS_SYNC_WRITE= yarn test-docs-sync`. This writes every region of the page into its fence, with the COPY values applied and the docs prettier style. Never paste snippets into the docs by hand. +3. Check `git diff` in `$DOCS_DIR`: only the intended fences may change. If others changed, a region was out of sync; go back to "Region differs from its fence". +4. Format and lint the page, from `$DOCS_DIR`: + ```sh + npx prettier --write + npx markdownlint-cli2 --config .markdownlint-cli2.cjs + ``` + Never run `npm run ci` or `npm run lint` for the whole docs repo: they rewrite thousands of files, including images. +5. Record the fix for the report: before/after, reason, and evidence (error message or type error). + +## 5. Gates + +All must pass before you finish: + +```sh +DOCS_DIR=$DOCS_DIR yarn test-docs-sync # no missing, mislabelled, unknown or differing snippets +yarn test-docs # green, no LEAK or DRIFT error +yarn types-docs # no type errors, no @ts-ignore / @ts-expect-error / `!` +yarn eslint test/docs-snippets # zero warnings +npx prettier --check test/docs-snippets +``` + +If you changed `src/`, also `yarn types`, `yarn lint` and `yarn test`. + +Run `yarn test-docs` only on the test files you changed: pages may change app settings, so never run several docs test files in parallel. + +## Rules + +- Don't commit, push, stash or create branches in either repo. +- Never write the API key or secret to any file except `test/docs-snippets/.env`. +- Don't edit `test/docs-snippets/AGENTS.md`, the helpers or the configs. Propose changes in the report instead. +- Change docs snippets as little as possible, and only when they don't work. +- Backend behavior issues that don't block the test (misleading error, inconsistent behavior) go to "Possible API issues" in `test/docs-snippets/docs-snippets-todo.md`, with the page and the exact error. + +## Report + +End with: + +1. **Docs folder** used, and both repos' branches. +2. **Tests updated from the docs**: per page, the regions changed and why the setup/assertions changed. +3. **New and removed tests**: snippets added, regions removed with their fences, renamed markers. +4. **Docs changes**: every fence changed in `$DOCS_DIR` (fixes, labels, removed server-only code), each with before/after, reason and evidence. These need human review. +5. **`src/` fixes**: each change as a diff, with the snippet that needed it. +6. **Open questions**: failing or blocked snippets you couldn't resolve, other-product fences to exclude, API issues added to the TODO. +7. **Proposed additions to `test/docs-snippets/AGENTS.md`**: short, reusable lessons. Skip page-specific details. diff --git a/test/docs-snippets/learnings.md b/test/docs-snippets/AGENTS.md similarity index 86% rename from test/docs-snippets/learnings.md rename to test/docs-snippets/AGENTS.md index 91590c330e..a7a2b57c8a 100644 --- a/test/docs-snippets/learnings.md +++ b/test/docs-snippets/AGENTS.md @@ -1,9 +1,30 @@ -# Docs snippet tests: learnings +# Docs snippet tests: agent notes -Lessons collected from agent runs. Read this before working on a page. -Entries are added only after the user approves them (agents propose them in their final report). +Read this before working on a page. **Test conventions** are the rules every test follows; **API quirks** are API and SDK behavior found while testing. +New entries are added only after the user approves them (agents propose them in their final report). -## Setup and tooling +## Test conventions + +### General + +- Import the SDK from `../../../src` (relative depth varies) and test helpers from `helpers/`. Use `import type` for types. +- `stream-chat` is aliased to `src/index.ts` (`resolve.alias` in `vitest.config.mts`, `paths` in `tsconfig.json`), so a docs fence that starts with `import ... from "stream-chat"` can be a region at module level: declare the values it uses (e.g. `apiKey`) above it. Both import paths load the same module, so `StreamChat.getInstance` returns the same singleton. +- Custom data the docs use (e.g. channel `name`) is declared once in `custom-types.d.ts` by augmenting the `Custom*Data` interfaces of `stream-chat`, the way the docs tell apps to. Add fields there rather than casting in tests. +- **Isolation**: the id or name of everything a test creates comes from `uniqueId('name')`: users, channels, channel types, roles, commands, blocklists, segments, campaigns (name), user groups, moderation config keys, predefined filters, push providers. Polls are matched by their creator, so create them as a `uniqueId` user. Never use fixed ids like `"john"` or `"general"`. +- **Cleanup**: register everything you create on a `Cleanup` instance and call `cleanup.run()` in `afterAll`. App-level settings changed by a test must be restored with `cleanup.add(...)`. +- Tests in one file may share data created in `beforeAll`. Nothing may leak across files. +- `cleanup.run()` attempts every step and then throws if anything failed, so leftovers show up as a failed `afterAll` instead of piling up in the app. Server-side hard channel deletion is a background task: `Cleanup` waits for it (`waitForTask`) and retries deleting channel types until their channels are gone. +- **Leak check** (`setup.ts`, runs automatically after each file's own `afterAll`): looks up everything minted by `uniqueId` in that file (users, channels, channel types, commands, roles, custom permissions, blocklists, segments, campaigns, user groups, moderation configs, predefined filters, push providers, polls). Anything still there fails the file with a `LEAK` error listing it, and is then removed. Per-user data (reminders, devices, drafts, ...) goes away with the user. It also snapshots app settings, pre-existing channel types and retention policies before the file and fails with `DRIFT` if anything changed and wasn't restored. +- Anything created under a non-`uniqueId` name can't be found by the leak check or the sweep. Don't do that. +- **Eventual consistency**: channel type changes (`createChannelType` / `updateChannelType`) take ~30s to reach every API node. A `create()` can succeed and the next call still fail with `: channel type does not exist`. Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after creating or updating a channel type. Timeouts are 90s per test and 180s per hook to leave room for this. Use `retry(fn, { retryIf })` for other eventually-consistent reads (e.g. search). +- **App settings**: pages are run one at a time, so tests may change app settings and built-in channel types, but must restore them (`cleanup.add`). The check after each file fails with `DRIFT` otherwise. +- **Guest users** get their id rewritten to `guest--`: register `client.userID` for cleanup. +- One `it` per docs snippet (or per closely related group of snippets in the same tab group). +- `getClientSideClient(user)` returns a connected client. Disconnect it with `disconnectClients` in `afterAll`. Use `getServerClient()` only for setup, cleanup and assertions that a client can't do (server calls need `user_id` / `created_by_id` where a client call would infer the user). +- The server client is `@stream-io/node-sdk` (`StreamClient`), not stream-chat-js: request objects (`chat.getMessage({ id })`), chat endpoints on `serverClient.chat`, custom data under `custom`, timestamps as `Date` objects. `upsertUsers(serverClient, users)` takes stream-chat-js-shaped users, `createUserToken(userId)` signs tokens, and `updateChannelType(serverClient, name, patch)` (`helpers/channel-types.ts`) fills in the fields node-sdk requires. `helpers/server.ts` has the repeated server-side steps: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy` and `waitForAppSetting`. +- Add basic `expect`s after each snippet to prove it did what the docs claim. + +### Setup and tooling - To probe API behavior quickly, write a throwaway vitest file under `test/docs-snippets/pages/` that logs each call's result or error. It still runs the leak check, so register what it creates, and delete the file before finishing. - Fences containing `import ... from "stream-chat"` can be regions: `stream-chat` is aliased to `src` (vitest `resolve.alias` + tsconfig `paths`). Put the region at module level, below the other imports, with the values it uses (e.g. `const apiKey`) declared above it, since a `const` after it would be in the TDZ. Example: `pages/chat/02-init_and_users/02-init_and_users.test.ts`. @@ -25,7 +46,7 @@ Entries are added only after the user approves them (agents propose them in thei - COPY substitution replaces the identifier everywhere in the region, including comments: a test variable `red` (COPY `red="red"`) turned the docs comment "for team red" into `for team "red"`. Name COPY variables so they can't match words in the docs text (`redTeam`, `channelId`). - A heading with double quotes goes into the marker escaped: `heading="1: Creating a \"Do Not Disturb\" Mode"` (marker attributes are parsed as JSON strings). -## Server client (`@stream-io/node-sdk`) +### Server client (`@stream-io/node-sdk`) - `getServerClient()` is a node-sdk `StreamClient`. Every method takes one request object; chat endpoints are on `serverClient.chat`, moderation on `serverClient.moderation`. - Custom data is under `custom` in node-sdk requests and responses (users, channels, members, messages); `upsertUsers(serverClient, users)` (`helpers/clients.ts`) converts stream-chat-js-shaped users. Timestamps come back as `Date` objects. Errors are `StreamError` (`Stream error code : `, `.code` = API code, HTTP status in `.metadata.responseCode`). @@ -35,7 +56,30 @@ Entries are added only after the user approves them (agents propose them in thei - `chat.createChannelType` requires `automod`, `automod_behavior` and `max_message_length`; use the `updateChannelType(serverClient, name, patch)` helper (`helpers/channel-types.ts`) for updates. - Server messages: `chat.channel(type, id).sendMessage({ message: { text, user_id }, pending, skip_push })`; attachments need `custom: {}` (TS2741 otherwise). Reactions: `chat.sendReaction({ id, reaction: { type, user_id } })`. -## API and SDK behavior +### Docs conventions + +- Unawaited async SDK calls in a snippet (`channel.sendSharedLocation(...)`, `manager.init()`) are a docs bug: errors only surface as unhandled rejections, and later lines run too early (e.g. `unregisterSubscriptions()` before `init()` registered anything). Add `await` in the docs instead of spying on the result. +- Undefined app-supplied placeholders (e.g. `tokenProvider`) aren't docs bugs: define them in the test just before the region (e.g. `const tokenProvider = () => Promise.resolve(createUserToken(userId))`, which also satisfies `require-await`), and don't change the docs. +- A fence that declares the same `const` twice (e.g. two `const result = ...` or `const filter = ...` showing alternatives) is a real SyntaxError and TS2451. Fix the docs by renaming only the later declarations with descriptive names (`aroundResult`, `membersFilter`); don't split the fence. +- A fence showing alternative operations on one placeholder id (e.g. soft delete, hard delete, delete for me on `messageID`) can fail when run in sequence (delete-for-me after a hard delete: 404 code 16). Fix the docs by giving the conflicting later call its own id (`anotherMessageID`); don't split the fence. +- Comment-only `JavaScript` fences ("This is a server-side only feature, choose any of our server-side SDKs...") stay in the docs. The sync check skips them, so they need no marker. +- When the docs use an identifier (not a string literal) like `randomID`, name the test variable the same and don't put it in COPY. A quoted COPY value (`randomID="randomID"`) turns it into a string literal, and `DOCS_SYNC_WRITE` then silently writes the quoted string into the docs. + +### Isolation and cleanup + +- Distinct channels (no id) get an auto id `!members-...`: push `channel.cid` to `cleanup.channels` right after `create()`/`watch()`, and use `uniqueId` members. +- Guest users: push `client.userID` (the rewritten `guest--`) to `cleanup.users` in a `finally` around the snippet, so it is cleaned up even if a later assertion fails. +- Some names are unique per app (e.g. user group names: `a group with name "Design Team" already exists`), and the leak check matches by id or name. Use a COPY variable from `uniqueId` for every such `name`, including the new name in update/rename snippets. +- Server-side `deleteUsers` of an id that was never created succeeds, so register a user on `cleanup` before the snippet creates it. +- Polls and campaigns are NOT deleted when their creator user is hard-deleted. Delete them explicitly via `cleanup.add(...)` (`serverClient.deletePoll({ poll_id, user_id })`; `serverClient.chat.deleteCampaign({ id })`). To also catch polls created inside regions, add `cleanup.add(() => deletePollsCreatedBy(serverClient, userId))` (`helpers/server.ts`), and give campaigns a `uniqueId(...)` name so the leak check can find them. +- To assert ordering or offset pagination exactly, create ~12 channels in `beforeAll` and send one message to each, sequentially (not `Promise.all`): `last_message_at: -1` order is then the reverse of creation order. Same for message pagination (`id_lt`/`id_around`): send ~30 messages sequentially, record their ids in order, and compare pages to exact slices. +- To assert member `created_at` order or `created_at_before` pagination, add members one at a time with `addMembers([id])` in `beforeAll`: members passed together to `create()` can share a `created_at`. +- To prove a "flagged first" sort (e.g. `pinned_at: -1`) is applied, flag a channel first and create a second channel after it: without the sort the newer one comes first, so `toEqual([flagged, other])` shows the sort worked. +- `cleanup.add` steps run after `disconnectClients`, so they can't use a user client ("Both secret and user tokens are not set"). Standalone uploads (`client.uploadImage`/`uploadFile`) aren't removed with the user or channel and the leak check can't see them: delete them in the test while the client is connected. + +## API quirks + +### API and SDK behavior - Since v9, channel `name` (and other fields like `image`) are custom data, not in `ChannelData`/`ChannelResponse`. `test/docs-snippets/custom-types.d.ts` augments `CustomChannelData` with `name`, so snippets passing `{ name }` typecheck as-is. If a snippet needs another custom field, add it there, using the matching interface (`CustomMemberData` for member fields like `code_name`, etc.); no casts or typed client views in tests. Custom event types (`type: "friendship_request"`) go in `CustomEventTypes` and their payload fields in `CustomEventData`; `channel.sendEvent` needs a watched channel. - To prove a channel is watched, check that its events reach the client: subscribe with `channel.on('message.new', ...)`, send a message server-side, and await the event. `client.activeChannels[cid]` proves nothing (`client.channel()` adds the channel there before any `watch()`), and `channel.watch()` returns `watchers` only when a watchers limit is requested. @@ -85,7 +129,7 @@ Entries are added only after the user approves them (agents propose them in thei - Multi-tenancy access is team-scoped: a channel only accepts members from its own team (`update({ add_members })`: code 5 "user from teams [...] cannot be a member of a channel with team ..."), a user without teams only sees channels without a team, and a team user's `queryChannels({ team: {} })` / `({ team: { $eq: null } })` fails with code 70 as soon as an inaccessible channel matches (documented on the page; even for a member, even for role `admin`). Cross-team reads need a `*-any-team` grant: the built-in `global_read_only` / `global_admin` roles have `read-channel-any-team`. - Push preferences (`setPushPreferences`) work without a push provider. Read them back client-side: user-level from a fresh `connectUser` response (`me.push_preferences`), channel-member level from `queryChannels`/`watch` (`channel.push_preferences`). Devices (`addDevice`) need a matching push provider on the app: the test app's providers are named (multi-bundle), so register with `addDevice(id, "firebase", undefined, process.env.STREAM_PUSH_PROVIDER_NAME)` (a Firebase provider's name, set in `.env`). Without the name: code 4 'push provider "" with type "firebase" not found'. -## Client-side vs server-side +### Client-side vs server-side - Server-only `JavaScript` snippets (they fail with a user token) are removed from the page. Don't add a `Node.js` tab for them (`Node.js` fences are out of scope). If only part of a snippet is server-only (e.g. setting `channel_role` in `updateMemberPartial`), remove just that part from the `JavaScript` tab and keep the rest. - If every `JavaScript` fence of a page turns out to be server-only (all removed), don't write a client test file: a test that only asserts the user-token rejection covers no docs snippet. Record the error in the report instead. @@ -97,24 +141,3 @@ Entries are added only after the user approves them (agents propose them in thei - Some client-side endpoints need global role `admin` (role `user` gets 403 code 17), e.g. `client.moderation.queryReviewQueue`. Connect a second user with `getClientSideClient({ id, role: 'admin' })` and shadow `const client = adminClient;` in the `it`. - Global role changes are server-only: client-side `partialUpdateUser({ set: { role } })` fails with 403 code 17 `UpdateUserRole`, even as `admin`. `getBlockedUsers(userId)` fails with code 4 "Setting user or user_id is only allowed when using server side auth": remove the argument (`blockUser`/`unBlockUser`/`getBlockedUsers()` work for the connected user). - Member role changes are server-only: client-side `addModerators`/`demoteModerators` fail with 403 code 17 ("changing channel member roles is not allowed client-side"), and `updateMemberPartial` with `channel_role` fails with code 17 ("this channel role can only be updated server side"). Client-side `updateMemberPartial` of custom fields works for the connected user without a `userId` option (don't pass one client-side: another user's id fails with 400 code 4, and your own is redundant). Client-side `addMembers([{ user_id, channel_role }])` for a new member does work (channel owner, `messaging`). - -## Docs conventions - -- Unawaited async SDK calls in a snippet (`channel.sendSharedLocation(...)`, `manager.init()`) are a docs bug: errors only surface as unhandled rejections, and later lines run too early (e.g. `unregisterSubscriptions()` before `init()` registered anything). Add `await` in the docs instead of spying on the result. -- Undefined app-supplied placeholders (e.g. `tokenProvider`) aren't docs bugs: define them in the test just before the region (e.g. `const tokenProvider = () => Promise.resolve(createUserToken(userId))`, which also satisfies `require-await`), and don't change the docs. -- A fence that declares the same `const` twice (e.g. two `const result = ...` or `const filter = ...` showing alternatives) is a real SyntaxError and TS2451. Fix the docs by renaming only the later declarations with descriptive names (`aroundResult`, `membersFilter`); don't split the fence. -- A fence showing alternative operations on one placeholder id (e.g. soft delete, hard delete, delete for me on `messageID`) can fail when run in sequence (delete-for-me after a hard delete: 404 code 16). Fix the docs by giving the conflicting later call its own id (`anotherMessageID`); don't split the fence. -- Comment-only `JavaScript` fences ("This is a server-side only feature, choose any of our server-side SDKs...") stay in the docs. The sync check skips them, so they need no marker. -- When the docs use an identifier (not a string literal) like `randomID`, name the test variable the same and don't put it in COPY. A quoted COPY value (`randomID="randomID"`) turns it into a string literal, and `DOCS_SYNC_WRITE` then silently writes the quoted string into the docs. - -## Isolation and cleanup - -- Distinct channels (no id) get an auto id `!members-...`: push `channel.cid` to `cleanup.channels` right after `create()`/`watch()`, and use `uniqueId` members. -- Guest users: push `client.userID` (the rewritten `guest--`) to `cleanup.users` in a `finally` around the snippet, so it is cleaned up even if a later assertion fails. -- Some names are unique per app (e.g. user group names: `a group with name "Design Team" already exists`), and the leak check matches by id or name. Use a COPY variable from `uniqueId` for every such `name`, including the new name in update/rename snippets. -- Server-side `deleteUsers` of an id that was never created succeeds, so register a user on `cleanup` before the snippet creates it. -- Polls and campaigns are NOT deleted when their creator user is hard-deleted. Delete them explicitly via `cleanup.add(...)` (`serverClient.deletePoll({ poll_id, user_id })`; `serverClient.chat.deleteCampaign({ id })`). To also catch polls created inside regions, add `cleanup.add(() => deletePollsCreatedBy(serverClient, userId))` (`helpers/server.ts`), and give campaigns a `uniqueId(...)` name so the leak check can find them. -- To assert ordering or offset pagination exactly, create ~12 channels in `beforeAll` and send one message to each, sequentially (not `Promise.all`): `last_message_at: -1` order is then the reverse of creation order. Same for message pagination (`id_lt`/`id_around`): send ~30 messages sequentially, record their ids in order, and compare pages to exact slices. -- To assert member `created_at` order or `created_at_before` pagination, add members one at a time with `addMembers([id])` in `beforeAll`: members passed together to `create()` can share a `created_at`. -- To prove a "flagged first" sort (e.g. `pinned_at: -1`) is applied, flag a channel first and create a second channel after it: without the sort the newer one comes first, so `toEqual([flagged, other])` shows the sort worked. -- `cleanup.add` steps run after `disconnectClients`, so they can't use a user client ("Both secret and user tokens are not set"). Standalone uploads (`client.uploadImage`/`uploadFile`) aren't removed with the user or channel and the leak check can't see them: delete them in the test while the client is connected. diff --git a/test/docs-snippets/README.md b/test/docs-snippets/README.md index cd794e5c00..3db6a16d91 100644 --- a/test/docs-snippets/README.md +++ b/test/docs-snippets/README.md @@ -19,6 +19,10 @@ yarn eslint test/docs-snippets && npx prettier --check test/docs-snippets # li These tests hit the network and are not part of `yarn test`. +## How to add tests? + +Run the `docs-sync` Claude Code skill (`.claude/skills/docs-sync/SKILL.md`): `/docs-sync [path to the getstream.io checkout]`. It runs `yarn test-docs-sync` against that checkout, adds tests for new `JavaScript` snippets, updates existing tests to docs changes (the docs win), and writes fixes for snippets that don't work back to the docs. + ## Layout ``` @@ -30,9 +34,9 @@ setup.ts # .env loading, per-file leak check custom-types.d.ts # Custom*Data augmentations for the custom fields the docs use sync/ # `yarn test-docs-sync` (docs fence <-> test region check) maintenance/ # `yarn test-docs-sweep` -docs-snippets-todo.md # pages to cover, their status, blockers and setup notes +docs-snippets-todo.md # pages to cover and their status, open generator and API issues docs-snippets-agent-prompt.md # prompt for an agent that covers one page -learnings.md # lessons from earlier agent runs, read before working on a page +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/`: @@ -42,22 +46,7 @@ The path mirrors the docs path relative to `content/docs/`, without `_default/`: ## Conventions -- Import the SDK from `../../../src` (relative depth varies) and test helpers from `helpers/`. Use `import type` for types. -- `stream-chat` is aliased to `src/index.ts` (`resolve.alias` in `vitest.config.mts`, `paths` in `tsconfig.json`), so a docs fence that starts with `import ... from "stream-chat"` can be a region at module level: declare the values it uses (e.g. `apiKey`) above it. Both import paths load the same module, so `StreamChat.getInstance` returns the same singleton. -- Custom data the docs use (e.g. channel `name`) is declared once in `custom-types.d.ts` by augmenting the `Custom*Data` interfaces of `stream-chat`, the way the docs tell apps to. Add fields there rather than casting in tests. -- **Isolation**: the id or name of everything a test creates comes from `uniqueId('name')`: users, channels, channel types, roles, commands, blocklists, segments, campaigns (name), user groups, moderation config keys, predefined filters, push providers. Polls are matched by their creator, so create them as a `uniqueId` user. Never use fixed ids like `"john"` or `"general"`. -- **Cleanup**: register everything you create on a `Cleanup` instance and call `cleanup.run()` in `afterAll`. App-level settings changed by a test must be restored with `cleanup.add(...)`. -- Tests in one file may share data created in `beforeAll`. Nothing may leak across files. -- `cleanup.run()` attempts every step and then throws if anything failed, so leftovers show up as a failed `afterAll` instead of piling up in the app. Server-side hard channel deletion is a background task: `Cleanup` waits for it (`waitForTask`) and retries deleting channel types until their channels are gone. -- **Leak check** (`setup.ts`, runs automatically after each file's own `afterAll`): looks up everything minted by `uniqueId` in that file (users, channels, channel types, commands, roles, custom permissions, blocklists, segments, campaigns, user groups, moderation configs, predefined filters, push providers, polls). Anything still there fails the file with a `LEAK` error listing it, and is then removed. Per-user data (reminders, devices, drafts, ...) goes away with the user. It also snapshots app settings, pre-existing channel types and retention policies before the file and fails with `DRIFT` if anything changed and wasn't restored. -- Anything created under a non-`uniqueId` name can't be found by the leak check or the sweep. Don't do that. -- **Eventual consistency**: channel type changes (`createChannelType` / `updateChannelType`) take ~30s to reach every API node. A `create()` can succeed and the next call still fail with `: channel type does not exist`. Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after creating or updating a channel type. Timeouts are 90s per test and 180s per hook to leave room for this. Use `retry(fn, { retryIf })` for other eventually-consistent reads (e.g. search). -- **App settings**: pages are run one at a time, so tests may change app settings and built-in channel types, but must restore them (`cleanup.add`). The check after each file fails with `DRIFT` otherwise. -- **Guest users** get their id rewritten to `guest--`: register `client.userID` for cleanup. -- One `it` per docs snippet (or per closely related group of snippets in the same tab group). -- `getClientSideClient(user)` returns a connected client. Disconnect it with `disconnectClients` in `afterAll`. Use `getServerClient()` only for setup, cleanup and assertions that a client can't do (server calls need `user_id` / `created_by_id` where a client call would infer the user). -- The server client is `@stream-io/node-sdk` (`StreamClient`), not stream-chat-js: request objects (`chat.getMessage({ id })`), chat endpoints on `serverClient.chat`, custom data under `custom`, timestamps as `Date` objects. `upsertUsers(serverClient, users)` takes stream-chat-js-shaped users, `createUserToken(userId)` signs tokens, and `updateChannelType(serverClient, name, patch)` (`helpers/channel-types.ts`) fills in the fields node-sdk requires. `helpers/server.ts` has the repeated server-side steps: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy` and `waitForAppSetting`. -- Add basic `expect`s after each snippet to prove it did what the docs claim. +The test conventions and the API quirks found so far are in [`AGENTS.md`](AGENTS.md). Read it before working on a page. ## Snippet markers and docs sync diff --git a/test/docs-snippets/docs-snippets-agent-prompt.md b/test/docs-snippets/docs-snippets-agent-prompt.md index a56e5263cb..df593e7d91 100644 --- a/test/docs-snippets/docs-snippets-agent-prompt.md +++ b/test/docs-snippets/docs-snippets-agent-prompt.md @@ -11,7 +11,7 @@ You are verifying the client-side JavaScript code snippets of one Stream Chat do ## Hard rules - **Do not commit, push, stash, or create branches** in any repo. Work only in the local working tree. -- **Do not write to `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/learnings.md`.** Read it, and propose additions in your final report. +- **Do not write to `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/AGENTS.md`.** Read it, and propose additions in your final report. - Only touch: - the test files for this page under `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/` - the docs page itself @@ -27,9 +27,9 @@ You are verifying the client-side JavaScript code snippets of one Stream Chat do ## Read first -1. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/learnings.md`: issues that earlier agents ran into. Follow it. -2. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/README.md`: layout, helpers, isolation, cleanup and snippet-marker conventions. Look at `pages/chat/04-messages/01-send_message.test.ts` as an example. -3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`. Also read the page's entries in the TODO's **Blockers detail**, **Setup notes** and **Known docs bugs** sections. +1. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/AGENTS.md`: test conventions and API quirks that earlier agents ran into. Follow it. +2. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/README.md`: layout, commands and snippet markers. Look at `pages/chat/04-messages/01-send_message.test.ts` as an example. +3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`. 4. When the page doesn't make clear whether an endpoint is client-side or server-side only, check the OpenAPI specs: https://github.com/GetStream/protocol/tree/main/openapi/v2. An endpoint missing from the client spec is server-only. Also check the SDK source in `stream-chat-js/src/` for the method's signature and types. @@ -69,9 +69,7 @@ You are verifying the client-side JavaScript code snippets of one Stream Chat do - **Channel types are eventually consistent.** After `createChannelType` or `updateChannelType`, API nodes can disagree for up to ~30s. A `create()` can succeed and the next `sendMessage` still fail with "channel type does not exist". - Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after the create/update, outside the snippet region. - Push the name to `cleanup.channelTypes` straight after `createChannelType`. `Cleanup` deletes it after the channels are gone. Never delete channel types in your own cleanup code. -- **Blockers** (the page's Blockers cell in the TODO): - - ⛔ snippets go in `it.skip('BLOCKED: ', ...)` so they are still typechecked. - - If you find a new blocker, use `it.skip('BLOCKED: ')`, mark the page `[~]` and report it, with the exact API error. +- **Blockers**: if the app doesn't support a snippet, put it in `it.skip('BLOCKED: ', ...)` so it is still typechecked, mark the page `[~]` and report it, with the exact API error. - **Guest users** get their id rewritten to `guest--`. Register `client.userID` for cleanup, not the id you passed. - **Partial snippets** (fragments like `filters = {...}` or `...` placeholders): wrap them in the minimal code needed to compile and run. The region still holds exactly the docs text. @@ -126,4 +124,4 @@ Finally, update the page's row in `/Users/zitaszupera/Stream/stream-chat-js/test 4. **Removed server-side code**: each removed fence or part of a fence, with the client-side error (or the reason) that shows it is server-only. 5. **stream-chat-js fixes**: every `src/` change as a diff, with the snippet that needed it. 6. **Open SDK issues and API issues**: stream-chat-js problems you couldn't fix, and the API behavior issues you added to the TODO. -7. **Proposed learnings** for `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/learnings.md`: a numbered list of short, reusable lessons that would have saved you time (e.g. "`channel.muteStatus()` needs a watched channel"). Skip page-specific trivia. They are reviewed before being added. +7. **Proposed additions** to `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/AGENTS.md`: a numbered list of short, reusable lessons that would have saved you time (e.g. "`channel.muteStatus()` needs a watched channel"). Skip page-specific trivia. They are reviewed before being added. diff --git a/test/docs-snippets/docs-snippets-todo.md b/test/docs-snippets/docs-snippets-todo.md index 741aeacf09..5fb1392f2d 100644 --- a/test/docs-snippets/docs-snippets-todo.md +++ b/test/docs-snippets/docs-snippets-todo.md @@ -1,187 +1,5 @@ # Docs snippet tests: todo -Pages under `getstream.io/content/docs/` that contain JavaScript snippets. Give each page to an agent with `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-agent-prompt.md`. **Run pages one at a time**: tests may change app settings (they must restore them). - -- Only client-side snippets are in scope: fences labelled `JavaScript` of pages in the chat JavaScript sidebar and the platform sidebar. On platform pages, only fences that use stream-chat-js count: Feeds sections are excluded (`EXCLUDED_SECTIONS` in `sync/parse.ts`). Unlabelled `js` fences get a label (`JavaScript` or `Node.js`). `Node.js` fences and pages only in the Node sidebar are out of scope. -- **JS / unlabelled**: number of `js` fences labelled `JavaScript` / with no label (before the docs were changed). -- **Client test**: `yes` when the page gets a test under `test/docs-snippets/pages/...` (`chat/_default/X` maps to `pages/chat/X`, `platform/X` to `pages/platform/X`); `-` when nothing on it can be tested client-side. - -**Blockers** (details below; empty = fully runnable, maybe with setup notes): - -- ⛔ **Blocked**: the app doesn't have the feature. The snippet goes in `it.skip('BLOCKED: ...')` (still typechecked). -- ◐ **Partial**: only some snippets of the page are affected. -- **Excluded**: not validated, leave as is. - -Status: `[ ]` todo, `[x]` done, `[~]` done with open questions. - -**v10 (2026-10-13):** every page below was ported to stream-chat-js v10 (tests + JavaScript fences on the getstream.io branch `stream-chat-js-v10-docs-snippets`); no page is blocked. Behaviour worth knowing: moderation v2 records only a user's first flag on a message (later flags don't update reason/custom). - -App facts (2026-10-05): push v3 (providers: type `firebase` named `firebase`, type `firebase` named `apn`; the docs' Firebase example config is rejected by `upsertPushProvider`), permissions v2, multi-tenancy off (can be toggled), campaigns on, no webhook/SQS/SNS. Enabled since (not probed yet): message history (`queryMessageHistory`). Probed and **working**: location sharing (enabled by support 2026-10-06), private messaging (restricted visibility, 2026-10-06), pending messages, delivery receipts, translation, review queue, guest users, dynamic partitioning config, multi-tenancy toggle. - -## chat/\_default/02-init_and_users - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ------------------------------------------------------------------------ | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/02-init_and_users/01-client_tokens_and_authentication.md` | 1 | 0 | yes | | -| [x] | `chat/_default/02-init_and_users/02-init_and_users.md` | 5 | 0 | yes | | -| [x] | `chat/_default/02-init_and_users/04-user_groups.md` | 8 | 0 | yes | | -| [x] | `chat/_default/02-init_and_users/05-authless_users.md` | 2 | 0 | yes | | - -## chat/\_default/03-channels - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ----------------------------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/03-channels/01-creating_channels.md` | 3 | 0 | yes | | -| [x] | `chat/_default/03-channels/02-query_channels.md` | 11 | 0 | yes | | -| [x] | `chat/_default/03-channels/03-channel_update.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/04-query_members.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/05-channel_pagination.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/06-channel_members.md` | 11 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/08-archiving.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/09-pinning.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/10-muting.md` | 3 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/11-hiding.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/12-disabling.md` | 1 | 1 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/13-deleting.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/14-freezing.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/15-truncating.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/16-channel_invites.md` | 6 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/17-batch-updates.md` | 3 | 0 | - | | - -## chat/\_default/04-messages - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | --------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/04-messages/01-send_message.md` | 9 | 0 | yes | | -| [x] | `chat/_default/04-messages/02-file_uploads.md` | 4 | 0 | yes | | -| [x] | `chat/_default/04-messages/03-threads.md` | 11 | 2 | yes | | -| [x] | `chat/_default/04-messages/04-send_reaction.md` | 5 | 0 | yes | | -| [x] | `chat/_default/04-messages/05-pinned_messages.md` | 3 | 0 | yes | | -| [x] | `chat/_default/04-messages/06-search.md` | 2 | 0 | yes | | -| [x] | `chat/_default/04-messages/07-silent_messages.md` | 2 | 0 | yes | | -| [x] | `chat/_default/04-messages/09-message_reminders.md` | 7 | 0 | yes | | -| [x] | `chat/_default/04-messages/10-message_receipts.md` | 1 | 0 | yes | | - -## chat/\_default/05-features - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ---------------------------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/05-features/02-events.md` | 7 | 0 | yes | | -| [x] | `chat/_default/05-features/03-unread.md` | 11 | 0 | yes | | -| [x] | `chat/_default/05-features/04-typing_indicators.md` | 2 | 0 | yes | | -| [x] | `chat/_default/05-features/05-presence_format.md` | 3 | 0 | yes | | -| [x] | `chat/_default/05-features/07-polls_api.md` | 17 | 0 | yes | | -| [x] | `chat/_default/05-features/08-location_sharing.md` | 7 | 0 | yes | | -| [x] | `chat/_default/05-features/09-translation.md` | 4 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md` | 2 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/12-drafts.md` | 6 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/13-private_messaging.md` | 1 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/15-pending_messages.md` | 1 | 0 | yes | | - -## chat/\_default/12-best_practices - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | -------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/12-best_practices/02-moderation.md` | 16 | 1 | yes | | - -## chat/javascript - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ---------------------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/javascript/01-quick_start/01-plain_js_introduction.md` | 4 | 0 | yes | | -| — | ~~`chat/javascript/11-debugging_and_cli/10-upgrading_to_v9.md`~~ | 0 | 8 | - | Excluded | - -## platform/02-identity-and-access - -**JS** counts only stream-chat-js fences; Feeds `JavaScript` fences in excluded sections are listed in the Blockers cell. - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ----------------------------------------------------- | --- | ---------- | ----------- | ------------------------------ | -| [x] | `platform/02-identity-and-access/02-users.md` | 0 | 0 | - | Excluded sections (2 Feeds JS) | -| [x] | `platform/02-identity-and-access/04-multi-tenancy.md` | 2 | 0 | yes | | - -## platform/03-events-and-notifications - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | --------------------------------------------------------------- | --- | ---------- | ----------- | ------------------------------ | -| [x] | `platform/03-events-and-notifications/01-webhooks.md` | 1 | 1 | yes | | -| [x] | `platform/03-events-and-notifications/02-push-notifications.md` | 0 | 0 | - | Excluded sections (2 Feeds JS) | -| [x] | `platform/03-events-and-notifications/04-push-devices.md` | 3 | 0 | yes | ◐ push provider config | -| [x] | `platform/03-events-and-notifications/05-push-preferences.md` | 9 | 0 | yes | Excluded sections (9 Feeds JS) | - -## platform/04-using-the-api - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ---------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `platform/04-using-the-api/06-connection-pooling.md` | 0 | 1 | - | | - -## Blockers detail - -### `chat/javascript/11-debugging_and_cli/10-upgrading_to_v9.md` (Excluded) - -- Not validated; leave the page as is (React/TSX type examples, no runnable JS). Listed in `EXCLUDED_PAGES` (`sync/parse.ts`), so the sync coverage and label checks skip it. - -### `platform/03-events-and-notifications/04-push-devices.md` (◐ push provider config) - -- The app has one push provider (added 2026-10-14): type `firebase`, named `firebase` (multi-bundle). Devices need both the type and the name: `addDevice(id, "firebase")` without the name fails with code 4 'push provider "" with type "firebase" not found'. The listing and removing tests register a device in setup with `addDevice(id, "firebase", undefined, name)`, where `name` comes from `STREAM_PUSH_PROVIDER_NAME` in `.env` (the name of a Firebase provider; the tests are skipped when it's empty). -- "Registering from the chat client SDKs" stays `it.skip('BLOCKED: ...')`. The fence shows two alternatives: `addDevice(id, "apn")` for single-provider apps and `addDevice(id, "apn", undefined, push_provider_name)` for multi-bundle apps. The test app is multi-bundle (named providers), and as the page documents, multi-bundle configurations require `push_provider_name`: the first call fails with code 4 'push provider "" with type "apn" not found'. One app can't run both lines. The app's provider named `apn` has type `firebase`, so the second line would also need an `apn`-type provider. - -### Excluded sections (Feeds) - -- `platform/02-identity-and-access/02-users.md` ("Guest users", "Anonymous users"), `platform/03-events-and-notifications/02-push-notifications.md` (all `JavaScript` / `React` / `React Native` fences), the Feeds fences of `04-push-devices.md` and the "Feeds Push Preferences" section of `05-push-preferences.md` use `@stream-io/feeds-client` (or notifee), not stream-chat-js. They're listed in `EXCLUDED_SECTIONS` (`sync/parse.ts`) and not validated. - -## Setup notes (not blockers) - -- **02-init_and_users/01-client_tokens_and_authentication**: `devToken` needs `disable_auth_checks: true`: toggle it for the test and restore it. -- **02-init_and_users/04-user_groups**: `team_id` / `team` need multi-tenancy. Toggle it server-side with `serverClient.updateApp({ multi_tenant_enabled })`, wait with `waitForAppSetting`, and restore it. -- **02-init_and_users/05-authless_users**: `setGuestUser` works. The guest id is rewritten to `guest--`: register `client.userID` (not the requested id) for cleanup. -- **03-channels/02-query_channels**: Predefined filter `user_messaging_channels` must be created first (`createPredefinedFilter` works via the API). -- **04-messages/02-file_uploads**: Browser `File` snippets: build `File` objects in the test. The custom CDN snippet (L682) references undefined `messageComposer` / `customCDN`. -- **04-messages/01-send_message**: URL enrichment depends on Stream scraping imgur (non-deterministic): assert loosely. Group mentions need a user group and permissions. -- **04-messages/05-pinned_messages**: `pinMessage(message, 120)` expires after 120s: don't assert after that. -- **04-messages/06-search**: Search indexing is eventually consistent: poll with `retry`. -- **04-messages/10-message_receipts**: Delivery receipts work on this app (probe: `last_delivered_at` set after `markChannelsDelivered`), despite the "contact support" note. -- **05-features/09-translation**: `translateMessage` works (probe). Enable auto-translation per channel server-side (`update({ data: { auto_translation_enabled: true } })`); no app setting to change. -- **05-features/10-advanced/11-slow_mode_and_throttling**: Slow mode cooldown is 30s in the snippet: use a short cooldown in assertions. -- **12-best_practices/02-moderation**: Review queue works client-side for a user with role `admin` (probe OK). -- **platform/02-identity-and-access/04-multi-tenancy**: Toggle multi-tenancy like `chat/_default/02-init_and_users/04-user_groups` (`serverClient.updateApp({ multi_tenant_enabled })` + `waitForAppSetting`, restore it). `team: "red"`: use a `uniqueId` team in a COPY variable. The "all teams" / "no team" queries need a user with `read-channel-any-team` (built-in `global_read_only` or `global_admin` role): for other users the API returns an access error (code 70) when inaccessible channels match, as the page documents. -- **platform/03-events-and-notifications/01-webhooks**: Don't verify that webhooks are delivered: asserting on the response or a getter is enough. The unlabelled fence ("Where each argument comes from") is node-sdk code: label it `Node.js`. -- **platform/03-events-and-notifications/04-push-devices**: Use the app's `firebase` provider (type and name `firebase`), see Blockers detail. Don't verify that a push arrives: `getDevices()` is enough. -- **platform/03-events-and-notifications/05-push-preferences**: Don't verify that pushes are delivered or suppressed. Setting preferences needs no push provider. Read them back client-side: user-level from a fresh `connectUser` (`me.push_preferences`), channel-member level from `queryChannels`/`watch` (`channel.push_preferences`). Channel preferences need an existing channel (code 16 "channel with CID ... does not exist"). -- **platform/04-using-the-api/06-connection-pooling**: The unlabelled fence ("SDK-specific notes") is node-sdk code (`StreamClient`, undici): label it `Node.js`; no test. -- **All pages that create/update channel types**: Wait 30s (`waitForChannelTypePropagation()`) after `createChannelType` / `updateChannelType` before using the type. - -## Known docs bugs - -Found while scanning the pages; agents should confirm and fix them (minimal change, listed in the final report). - -- **02-init_and_users/02-init_and_users**: `tokenProvider` undefined (L82); XHR fallback (L343) uses curly quotes and `enableWSFallBack` (SDK: `enableWSFallback`). -- **02-init_and_users/04-user_groups**: Users alice/bob/charlie/dave must exist. -- **03-channels/02-query_channels**: `thirtyDaysAgo`, `userId`, `channelCID` undefined (L857, L1490); fragment snippets redeclare `const filter`. -- **03-channels/06-channel_members**: `cutoff.setDate(date.getDate() - 7)`: `date` undefined (L504). -- **03-channels/07-channel_management/10-muting**: Uses `moment` (not a dependency); `expiration` is typed `number`. -- **03-channels/07-channel_management/14-freezing**: Missing `}` in `{ set: { frozen: true }` (L12); `const update` declared twice. -- **03-channels/07-channel_management/15-truncating**: Missing comma after `'text'` (L22); `user` undefined. -- **03-channels/07-channel_management/16-channel_invites**: `rejected` declared twice and missing `await` (L545, L693). -- **04-messages/01-send_message**: `josh` undefined (attachments). -- **04-messages/04-send_reaction**: `const reaction` / `const response` redeclared. -- **04-messages/07-silent_messages**: `systemUser`, `tripData` undefined. -- **04-messages/09-message_reminders**: Positional `createReminder("message-id","user-id",date)` / `updateReminder(...)`: the SDK takes an object (`createReminder({ messageId, ... })`); `offsetMs` undefined. -- **05-features/02-events**: `myClientEventListener` / `myChannelEventListener` only defined in comments (L556). -- **05-features/07-polls_api**: Syntax errors around L305, L516-517, L800. -- **12-best_practices/02-moderation**: `const flag` declared twice (L887); `console.log(next)` undefined (L1059); `ctx.createUsers` / `ctx.serverClient()` test-harness code (L1205). - -## Possible SDK issues (check when migrating the snippets to v10) - -Found while testing the v9 snippets. Not fixed: leave v9 as is. - -- **`ThreadManager.loadNextPage()`** (`04-messages/03-threads`): a silent no-op until the first page is loaded (`nextCursor` starts `null`); the first page needs `reload()`. Should it load the first page when not `ready`? -- **`BasePaginator`** (`04-messages/09-message_reminders`, `src/pagination/BasePaginator.ts`): `hasPrev` starts `true` and is only updated in cursor mode, so `queryPrevious…` on a first page without cursors re-fetches and appends it (duplicates, e.g. 4 reminders become 8). `getStateAfterQuery` always appends, also for the `prev` direction, so an earlier page in cursor mode is added at the end. -- **`ReminderManager`** (`04-messages/09-message_reminders`): paginator results only reach `client.reminders.state` after `client.reminders.registerSubscriptions()`, which the docs never call; `client.reminders.reminders` stays empty. By design? -- **Search `previous` cursor** (`04-messages/06-search`, backend): `next: page2.previous` returns page 1's messages in reverse order (the cursor flips every sort direction, results aren't flipped back). Backend bug, or document it. -- **Reminder pagination** (`04-messages/09-message_reminders`, backend): with the default sort and a page boundary on a `remind_at: null` reminder, the `next` cursor returned an empty page although more reminders existed. -- **`PollOptionResponse` custom data** (`05-features/07-polls_api`, `src/types.ts`): extends `CustomPollData` instead of `CustomPollOptionData`, so custom option fields aren't typed on option responses (or in `PartialPollOptionUpdate.set`). Fixing it is a breaking type change for apps that put option fields on `CustomPollData`: do it in v10. - ## Generator fixes (`../chat`, `tools/openapi`) Found while migrating the snippets to v10 and patched by hand in `src/gen/models/index.ts`. A `yarn generate-client` overwrites the patches, so fix them at the source, regenerate, and check that `yarn types`, `yarn types:tests` and the docs tests still pass. @@ -197,7 +15,3 @@ Found while testing. Not blockers. - **Multi-tenancy, code 70 hint** (`platform/02-identity-and-access/04-multi-tenancy`, backend): the access error for cross-team queries (documented: "an access error will be returned") suggests "Did you forget to include {members: $in: [...]}?" even when the filter already has it. Misleading hint. - **Multi-tenancy, cross-team membership** (backend): server-side `getOrCreate({ data: { team, members } })` accepts members from other teams, but `update({ add_members })` rejects them (code 5 "user from teams [...] cannot be a member of a channel with team ..."). Inconsistent. - **Push preferences response** (backend): the `setPushPreferences` response for a channel-only update has `user_preferences: { : null }`. - -## Notes - -- `chat/_default/03-channels/07-channel_management/17-batch-updates.md`: all `JavaScript` fences were server-only (batch updates and `getTask` fail with a user token: "this endpoint can only be called server side") and were removed, so the page has no client test. From fa4f3e5b3a73d50b63d43540ebe5e90fe78b903f Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 15 Oct 2026 11:51:23 -0500 Subject: [PATCH 06/10] chore: cleanup 2 --- .claude/skills/docs-sync/SKILL.md | 3 + test/docs-snippets/README.md | 1 - .../docs-snippets-agent-prompt.md | 127 ------------------ 3 files changed, 3 insertions(+), 128 deletions(-) delete mode 100644 test/docs-snippets/docs-snippets-agent-prompt.md diff --git a/.claude/skills/docs-sync/SKILL.md b/.claude/skills/docs-sync/SKILL.md index 8e0b20369f..d90c9eab34 100644 --- a/.claude/skills/docs-sync/SKILL.md +++ b/.claude/skills/docs-sync/SKILL.md @@ -92,6 +92,7 @@ Write a test for it in the page's test file (create the file if the page is new) - Run the snippet with a user token (`getClientSideClient(user)`). Use `getServerClient()` (a `@stream-io/node-sdk` client) only for setup, cleanup and assertions a client can't make. - 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 (e.g. "This is a server-side only feature...") 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 `JavaScript` 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. - **Server-only code in a `JavaScript` fence** (fails with a user token, e.g. 403 code 17 or "only allowed when using server side auth"): remove that fence, or just the server-only part, from the docs. Don't add a `Node.js` tab for it. To decide whether an endpoint or an acting-user field (`user_id`, `created_by_id`, ...) is client-side, check the client-side OpenAPI spec (`chat-clientside-api.json` in https://github.com/GetStream/protocol/tree/main/openapi/v2): an endpoint or field missing from it is server-only. Report every removal with the error. - A fence from another product (Feeds, `@stream-io/feeds-client`, React / React Native tabs on platform pages) isn't tested: report the page and heading so it can be added to `EXCLUDED_SECTIONS` in `test/docs-snippets/sync/parse.ts`. Don't edit `parse.ts` without asking the user. @@ -123,6 +124,8 @@ yarn eslint test/docs-snippets # zero warnings npx prettier --check test/docs-snippets ``` +Never silence a docs snippet with `@ts-ignore` / `@ts-expect-error`: a type error in a snippet is a docs bug or an SDK issue. + If you changed `src/`, also `yarn types`, `yarn lint` and `yarn test`. Run `yarn test-docs` only on the test files you changed: pages may change app settings, so never run several docs test files in parallel. diff --git a/test/docs-snippets/README.md b/test/docs-snippets/README.md index 3db6a16d91..7e47c1f35b 100644 --- a/test/docs-snippets/README.md +++ b/test/docs-snippets/README.md @@ -35,7 +35,6 @@ custom-types.d.ts # Custom*Data augmentations for the sync/ # `yarn test-docs-sync` (docs fence <-> test region check) maintenance/ # `yarn test-docs-sweep` docs-snippets-todo.md # pages to cover and their status, open generator and API issues -docs-snippets-agent-prompt.md # prompt for an agent that covers one page AGENTS.md # test conventions and API quirks, read before working on a page ``` diff --git a/test/docs-snippets/docs-snippets-agent-prompt.md b/test/docs-snippets/docs-snippets-agent-prompt.md deleted file mode 100644 index df593e7d91..0000000000 --- a/test/docs-snippets/docs-snippets-agent-prompt.md +++ /dev/null @@ -1,127 +0,0 @@ -# Agent prompt: test the docs snippets of one page - -Replace `{{PAGE}}` with a path from `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`, e.g. `chat/_default/04-messages/01-send_message.md` or `platform/02-identity-and-access/04-multi-tenancy.md`. - ---- - -You are verifying the client-side JavaScript code snippets of one Stream Chat docs page by writing typed tests that run them against a real Stream app with a user token. Only fences labelled `JavaScript` are in scope. Docs fences must always have a label: if you find an unlabelled `js` fence, label it (`JavaScript` for client code, `Node.js` for server-side code, with or without `@stream-io/node-sdk`) and report it. `Node.js` fences are out of scope: don't test, add or change them. - -**Page:** `/Users/zitaszupera/Stream/getstream.io/content/docs/{{PAGE}}` - -## Hard rules - -- **Do not commit, push, stash, or create branches** in any repo. Work only in the local working tree. -- **Do not write to `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/AGENTS.md`.** Read it, and propose additions in your final report. -- Only touch: - - the test files for this page under `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/` - - the docs page itself - - this page's status row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md` - - `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/custom-types.d.ts`, to add custom data fields the snippets use (augment the matching `Custom*Data` interface; never remove or change existing fields) - - `stream-chat-js/src/`, to fix SDK issues (see below) -- Do not edit the helpers, the configs, or other pages. If a helper is missing something, write a local helper in your test file and propose the shared helper in your report. -- **Fix stream-chat-js issues in `src/`** when a correct docs snippet hits one: a wrong or missing type (e.g. a field in `src/types.ts`, a too-narrow parameter type) or an SDK bug. Make the smallest fix; for a runtime fix, add or update a unit test under `test/unit/`. After the edit, `yarn types`, `yarn lint` and `yarn test` must still pass. List every change in the report. -- **API (backend) behavior issues** are not blockers: if the API behaves unexpectedly (inconsistent, misleading error, missing field) but the snippet can still be tested, test what works, add the issue to the "Possible API issues" list in the TODO (page, behavior, exact error), and carry on. -- Change existing docs snippets as little as possible, and only when they are actually wrong. -- Never put the API key or secret in any file. They are loaded from `test/docs-snippets/.env`. -- In getstream.io, never run `npm run ci` or `npm run lint` for the whole repo. They rewrite thousands of files, including image assets. Use only the per-file commands listed below. - -## Read first - -1. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/AGENTS.md`: test conventions and API quirks that earlier agents ran into. Follow it. -2. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/README.md`: layout, commands and snippet markers. Look at `pages/chat/04-messages/01-send_message.test.ts` as an example. -3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`. - -4. When the page doesn't make clear whether an endpoint is client-side or server-side only, check the OpenAPI specs: https://github.com/GetStream/protocol/tree/main/openapi/v2. An endpoint missing from the client spec is server-only. Also check the SDK source in `stream-chat-js/src/` for the method's signature and types. - -## What to build - -- **Client test** (`test/docs-snippets/pages/
/.test.ts`): run each `JavaScript` snippet with a user token, using `getClientSideClient(user)`. Use `getServerClient()` only for setup, cleanup and assertions a client can't do. It returns a `@stream-io/node-sdk` client (see the README's server client notes), never a stream-chat-js `StreamChat` with the secret. - - If a snippet (or part of it) only works server-side (needs the secret, `user_id` on behalf of others, app settings, ...), it doesn't belong in a client tab: remove it from the page (see "Server-side code in JavaScript snippets") and list it in the report. Don't add a `Node.js` tab for it. - - If every `JavaScript` fence of the page turns out to be server-only, don't write a test file. -- **Mirror paths.** The test paths mirror the docs path relative to `content/docs/`, without `_default/`: `chat/_default/04-messages/...` goes under `pages/chat/04-messages/...`, `chat/javascript/...` under `pages/chat/javascript/...`, `platform/...` under `pages/platform/...`. -- **Other products (platform pages).** Platform pages also have Feeds and Video fences. Only fences that use stream-chat-js are in scope (Chat, and Video `call_level` push preferences set through the chat client). Feeds fences (`@stream-io/feeds-client`, `useCreateFeedsClient`, `feeds_level`, `addComment`, `createDevice`/`listDevices`/`deleteDevice`) and `React` / `React Native` tabs are excluded through `EXCLUDED_SECTIONS` in `sync/parse.ts`. If you find a non-stream-chat-js fence the sync check still asks you to cover, don't test it and don't edit `sync/parse.ts`: report it with the heading to exclude. -- **Test structure:** - - `describe` per page. - - `beforeAll` creates the shared users and channels with `uniqueId(...)` ids and registers them on a `Cleanup`. - - `afterAll` disconnects the clients and runs `cleanup.run()`. - - One `it` per snippet, or per tab group when the snippets in it depend on each other. -- **Snippet markers.** Every `JavaScript` fence on the page needs exactly one marker in a test (`Node.js` fences are ignored). `yarn test-docs-sync` checks the format and the coverage. - - Wrap the docs code like this: - ```ts - // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 - // COPY: channelId="general", userId="john" - ...docs code... - // #endregion snippet - ``` - - `docs` is the page path relative to `content/docs/` (same as `{{PAGE}}`). - - `heading` is the exact text of the nearest heading above the fence. - - `tab` is the fence label, or `unlabelled`. - - `index` (default 1) counts fences with the same heading text and tab on the page. If a key is wrong, the sync check lists the page's fence keys. - - Everything between the markers must be the docs code. Setup goes before the region and assertions after it. - - **COPY** (optional, first line(s) of the region): `name="docs literal"` pairs. Where the docs use fixed values ("john", "general", "messageID"), use variables in the test, and the sync check replaces each variable with its literal before comparing. Don't use COPY variables as object keys or in shorthand properties (`{ userId }`). - - Comment-only fences (e.g. `// This is a server-side only feature...`) are skipped by the sync check and need no marker. Every other `JavaScript` fence gets a real region: blocked or untestable snippets go inside `it.skip('BLOCKED: ...')` so they are still typechecked. A fence that isn't JavaScript code (e.g. a bare data shape) is a docs bug: rewrite it as code that runs (e.g. reading and logging the fields), and report it. -- **Assertions.** After each snippet, add basic `expect`s proving that it did what the docs say. For example, check that the response contains the new field, or re-query and check that the state changed. Don't over-assert on volatile fields. -- **Naming**: the id or name of every resource you create comes from `uniqueId(...)` (campaigns: `name`; polls: create them as a `uniqueId` user). The automatic leak check after each file only finds resources named this way. If it reports a `LEAK`, fix your cleanup; don't work around the check. -- **App-level side effects** (app settings, built-in channel types, grants, roles, retention policies, multi-tenancy, push config, ...): pages are run **one at a time**, so changing them is allowed. - - Read the original value first and restore it with `cleanup.add(...)`. - - The check after each file fails with `DRIFT` if app settings or pre-existing channel types differ from before the file ran. - - Prefer `uniqueId` channel types or `config_overrides` when the snippet allows it. -- **Channel types are eventually consistent.** After `createChannelType` or `updateChannelType`, API nodes can disagree for up to ~30s. A `create()` can succeed and the next `sendMessage` still fail with "channel type does not exist". - - Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after the create/update, outside the snippet region. - - Push the name to `cleanup.channelTypes` straight after `createChannelType`. `Cleanup` deletes it after the channels are gone. Never delete channel types in your own cleanup code. -- **Blockers**: if the app doesn't support a snippet, put it in `it.skip('BLOCKED: ', ...)` so it is still typechecked, mark the page `[~]` and report it, with the exact API error. -- **Guest users** get their id rewritten to `guest--`. Register `client.userID` for cleanup, not the id you passed. -- **Partial snippets** (fragments like `filters = {...}` or `...` placeholders): wrap them in the minimal code needed to compile and run. The region still holds exactly the docs text. - -## Server-side code in JavaScript snippets - -- If a `JavaScript` snippet is server-only (it fails with a user token, e.g. 403 code 17 or "only allowed when using server side auth"), **remove the `JavaScript` fence**. Don't add or change a `Node.js` tab. Record the exact client-side error in the report. -- If only part of the snippet is server-only (e.g. one field like `channel_role`, or a line naming the acting user), remove just that part from the `JavaScript` fence and keep the rest. Probe each part client-side before deciding. -- Acting-user fields (`user_id`, `user`, `created_by_id`, a `userId` option naming who performs the action): remove one only if the client-side OpenAPI spec (`chat-clientside-api.json`, see "Read first") doesn't have it on that endpoint's request schema. If it does, keep it and test it. Cite the operation id and schema in the report either way. -- Don't remove a `JavaScript` fence just because it is a fragment or untestable for other reasons. - -## Editing the docs page - -- Write fixes from the test with `DOCS_SYNC_WRITE= yarn test-docs-sync`. This writes every region of that page into its fence, applying the COPY values and the docs prettier style (double quotes). Don't paste snippets by hand. -- Follow `/Users/zitaszupera/Stream/getstream.io/content/docs/AGENTS.md`. In particular, no em-dashes in prose or comments. -- Then run, from `/Users/zitaszupera/Stream/getstream.io/content/docs`: - - `npx prettier --write ` - - `npx markdownlint-cli2 --config .markdownlint-cli2.cjs ` - -## Faulty docs snippets - -If a docs snippet is wrong (wrong method name or signature, a type error against `src/`, wrong response shape, a runtime API error that isn't caused by test setup): - -1. Make the smallest fix that makes it correct, in the test region. -2. Write it to the docs page with `DOCS_SYNC_WRITE= yarn test-docs-sync`, then check `git diff` in getstream.io: only the intended fence should change. -3. Record it for the report as before/after with the reason and the evidence (error message or type error). - -If the snippet is correct but stream-chat-js rejects it (types or runtime), fix `src/` instead of the snippet. - -If you're not sure whether the snippet or your setup is wrong, don't edit the docs. Mark it as an open question in the report instead. - -## Gates (all must pass before you finish) - -Run these from `/Users/zitaszupera/Stream/stream-chat-js`: - -```sh -yarn test-docs # green, with no `LEAK` or `DRIFT` error -yarn types-docs # no type errors (strict TS, no `any` escapes, no `!` non-null assertions) -yarn eslint test/docs-snippets # zero warnings (same rules as src/: `import type`, sorted import members, no `!`, no unused vars) -npx prettier --check test/docs-snippets -yarn test-docs-sync # every JavaScript fence on the tested sidebars (chat JS, platform) covered, every JS fence labelled JavaScript / Node.js, every region identical to its docs fence -``` - -Don't use `@ts-ignore`/`@ts-expect-error` to silence a docs snippet. A type error in a snippet is either a docs bug (fix it as above) or an SDK typing bug (fix the type in `src/` and report it). - -Finally, update the page's row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`: `[x]` when done, `[~]` when there are open questions. - -## Final report (your last message) - -1. **Files created/changed**: test files, plus the docs page with a one-line summary. -2. **Docs snippet corrections**: for each one, a before/after diff, the reason, and the evidence. These need human review. -3. **Skipped / untestable snippets**: for each one, the snippet and why. -4. **Removed server-side code**: each removed fence or part of a fence, with the client-side error (or the reason) that shows it is server-only. -5. **stream-chat-js fixes**: every `src/` change as a diff, with the snippet that needed it. -6. **Open SDK issues and API issues**: stream-chat-js problems you couldn't fix, and the API behavior issues you added to the TODO. -7. **Proposed additions** to `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/AGENTS.md`: a numbered list of short, reusable lessons that would have saved you time (e.g. "`channel.muteStatus()` needs a watched channel"). Skip page-specific trivia. They are reviewed before being added. From 6f91ff5f35d65faf4e821c023b407e196b77ab74 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 15 Oct 2026 13:32:21 -0500 Subject: [PATCH 07/10] chore: sync from docs; more cleanup --- .claude/skills/docs-sync/SKILL.md | 7 +- test/docs-snippets/README.md | 2 +- test/docs-snippets/docs-snippets-todo.md | 8 -- .../03-channels/02-query_channels.test.ts | 91 ++++++++++++++++++- 4 files changed, 95 insertions(+), 13 deletions(-) diff --git a/.claude/skills/docs-sync/SKILL.md b/.claude/skills/docs-sync/SKILL.md index d90c9eab34..b5ef47f723 100644 --- a/.claude/skills/docs-sync/SKILL.md +++ b/.claude/skills/docs-sync/SKILL.md @@ -136,7 +136,7 @@ Run `yarn test-docs` only on the test files you changed: pages may change app se - Never write the API key or secret to any file except `test/docs-snippets/.env`. - Don't edit `test/docs-snippets/AGENTS.md`, the helpers or the configs. Propose changes in the report instead. - Change docs snippets as little as possible, and only when they don't work. -- Backend behavior issues that don't block the test (misleading error, inconsistent behavior) go to "Possible API issues" in `test/docs-snippets/docs-snippets-todo.md`, with the page and the exact error. +- API and API spec issues (misleading error, inconsistent behavior, a backend that doesn't match the docs) go in the report only, with the page and the exact error. Don't write them to `test/docs-snippets/docs-snippets-todo.md` or any other file. ## Report @@ -147,5 +147,6 @@ End with: 3. **New and removed tests**: snippets added, regions removed with their fences, renamed markers. 4. **Docs changes**: every fence changed in `$DOCS_DIR` (fixes, labels, removed server-only code), each with before/after, reason and evidence. These need human review. 5. **`src/` fixes**: each change as a diff, with the snippet that needed it. -6. **Open questions**: failing or blocked snippets you couldn't resolve, other-product fences to exclude, API issues added to the TODO. -7. **Proposed additions to `test/docs-snippets/AGENTS.md`**: short, reusable lessons. Skip page-specific details. +6. **Open questions**: failing or blocked snippets you couldn't resolve, other-product fences to exclude. +7. **API issues**: API and API spec issues found while testing, each with the page and the exact error. +8. **Proposed additions to `test/docs-snippets/AGENTS.md`**: short, reusable lessons. Skip page-specific details. diff --git a/test/docs-snippets/README.md b/test/docs-snippets/README.md index 7e47c1f35b..e7e9c23e5f 100644 --- a/test/docs-snippets/README.md +++ b/test/docs-snippets/README.md @@ -34,7 +34,7 @@ setup.ts # .env loading, per-file leak check custom-types.d.ts # Custom*Data augmentations for the custom fields the docs use sync/ # `yarn test-docs-sync` (docs fence <-> test region check) maintenance/ # `yarn test-docs-sweep` -docs-snippets-todo.md # pages to cover and their status, open generator and API issues +docs-snippets-todo.md # pages to cover and their status, open generator fixes AGENTS.md # test conventions and API quirks, read before working on a page ``` diff --git a/test/docs-snippets/docs-snippets-todo.md b/test/docs-snippets/docs-snippets-todo.md index 5fb1392f2d..ff788d23cd 100644 --- a/test/docs-snippets/docs-snippets-todo.md +++ b/test/docs-snippets/docs-snippets-todo.md @@ -7,11 +7,3 @@ Found while migrating the snippets to v10 and patched by hand in `src/gen/models - [ ] **Object-valued custom fields in filters** (`generator/templates/templates/typescript/models.tpl`, `CustomHelper`): a typed `custom.` key is built for every custom data field, including object-valued ones (e.g. `CustomChannelData.source_detail: { user_id: number }`). Its value doesn't fit the `custom.${string}` index signature (`string | boolean | number | Date`), so every filter type built from the custom data stops being assignable, including the SDK's own search sources (`ChannelSearchSource`, `MessageSearchSource`). Fix: only emit typed keys for primitive and `Date` fields (`NonNullable extends string | boolean | number | Date`); object fields fall back to the index signature, which already accepts nested paths like `custom.source_detail.user_id`. Covered by `test/types/custom-filters.ts`. - [ ] **Request dates typed as response dates** (OpenAPI spec): `created_at_after`, `created_at_after_or_equal`, `created_at_before` and `created_at_before_or_equal` on `QueryMembersPayload`, `QueryBannedUsersPayload` and `QueryFutureChannelBansPayload` are typed `TimestampNS` (the spec types them as numbers, the server-sent direction). They are request fields, sent in the JSON `payload` query param, and the server parses them as RFC 3339: a number fails with `Time.UnmarshalJSON: input is not a JSON string` (`03-channels/04-query_members`, "Paginate by created_at"). Fix the spec so they are request (string) dates, which the generator types as `Date`. Also check `ChannelStopWatchingRequest` (`created_at`, `received_at` typed `TimestampNS`), not investigated. - [ ] **Empty `team` channel filter** (OpenAPI spec + `models.tpl`, `Filters`): with multi-tenancy, `team: {}` queries channels across all teams (`platform/02-identity-and-access/04-multi-tenancy`, "Querying chat channels"), but every filter key only accepted an operator object with exactly one operator. Patched by hand: an `allowEmpty: true` flag on the `team` entries of `QueryChannelsRequest` and `SearchPayload` (both are `ChannelFilters`, and `MessageSearchSource` assigns one to the other), which `Filters` turns into an extra `Record` member. Fix: have the spec mark the key and the template emit the flag and the `Filters` branch. Covered by `test/types/team-filter.ts`. - -## Possible API issues - -Found while testing. Not blockers. - -- **Multi-tenancy, code 70 hint** (`platform/02-identity-and-access/04-multi-tenancy`, backend): the access error for cross-team queries (documented: "an access error will be returned") suggests "Did you forget to include {members: $in: [...]}?" even when the filter already has it. Misleading hint. -- **Multi-tenancy, cross-team membership** (backend): server-side `getOrCreate({ data: { team, members } })` accepts members from other teams, but `update({ add_members })` rejects them (code 5 "user from teams [...] cannot be a member of a channel with team ..."). Inconsistent. -- **Push preferences response** (backend): the `setPushPreferences` response for a channel-only update has `user_preferences: { : null }`. diff --git a/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts b/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts index 8a7056353c..21be6d5e60 100644 --- a/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../../src'; +import type { ChannelFilters, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -262,4 +262,93 @@ describe(DOCS, () => { expect(channels.map((c) => c.cid)).toEqual(newestFirst); }); + + // Own user and channels: hiding or muting the shared ones would change the other tests' results. + describe('hidden and muted channels', () => { + const memberId = uniqueId('member'); + // The docs' placeholder value. + const userID = memberId; + const visibleCid = `messaging:${uniqueId('visible')}`; + const hiddenCid = `messaging:${uniqueId('hidden')}`; + const mutedCid = `messaging:${uniqueId('muted')}`; + let memberClient: StreamChat; + + const queryCids = async (filter_conditions: ChannelFilters) => { + const channels = await memberClient.queryChannelsAndHydrate({ + filter_conditions, + limit: 30, + }); + return channels.map((c) => c.cid).sort(); + }; + + beforeAll(async () => { + cleanup.users.push(memberId); + cleanup.channels.push(visibleCid, hiddenCid, mutedCid); + memberClient = await getClientSideClient({ id: memberId }); + for (const cid of [visibleCid, hiddenCid, mutedCid]) { + const [type, id] = cid.split(':'); + await serverClient.chat.channel(type, id).getOrCreate({ + data: { members: [{ user_id: memberId }], created_by_id: other }, + }); + } + const [hiddenType, hiddenId] = hiddenCid.split(':'); + await serverClient.chat.channel(hiddenType, hiddenId).hide({ user_id: memberId }); + await serverClient.chat.muteChannel({ + channel_cids: [mutedCid], + user_id: memberId, + }); + }); + + afterAll(async () => { + await disconnectClients(memberClient); + }); + + it.skip("BLOCKED: lists a user's hidden and visible channels (the test app leaves the hidden channel out of a `$or: [{ hidden: true }, { hidden: false }]` filter unless it has a top-level `cid: { $in }`)", async () => { + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Listing a User's Channels" tab="JavaScript" index=1 + const filter = { + type: 'messaging', + members: { $in: [userID] }, + $or: [{ hidden: true }, { hidden: false }], + }; + // #endregion snippet + + expect(await queryCids(filter)).toEqual([visibleCid, hiddenCid, mutedCid].sort()); + // Without the `hidden` condition, the hidden channel is left out. + expect(await queryCids({ type: 'messaging', members: { $in: [userID] } })).toEqual( + [visibleCid, mutedCid].sort(), + ); + }); + + it('queries channels by id together with membership', async () => { + // The channels where the user has the member-level setting, kept in your backend. + const cids = [visibleCid, hiddenCid]; + + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Filtering by Member Data" tab="JavaScript" index=1 + const filter = { + cid: { $in: cids }, + members: { $in: [userID] }, + $or: [{ hidden: true }, { hidden: false }], + }; + // #endregion snippet + + expect(await queryCids(filter)).toEqual([visibleCid, hiddenCid].sort()); + }); + + it.skip('BLOCKED: combines the ids with muted channels (the test app leaves the hidden channel out: the `hidden` $or does not include hidden channels without a top-level `cid: { $in }`)', async () => { + const cids = [hiddenCid]; + + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Filtering by Member Data" tab="JavaScript" index=2 + const filter = { + members: { $in: [userID] }, + $and: [ + { $or: [{ cid: { $in: cids } }, { muted: true }] }, + { $or: [{ hidden: true }, { hidden: false }] }, + ], + }; + // #endregion snippet + + // The hidden channel by id, the muted one by `muted`; the visible one matches neither. + expect(await queryCids(filter)).toEqual([hiddenCid, mutedCid].sort()); + }); + }); }); From 80ffdc10b83f76fac3f789167e7fde39ca572456 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 15 Oct 2026 14:06:26 -0500 Subject: [PATCH 08/10] feat: regenerate API; update docs snippet todo --- src/gen/chat/ChannelApi.ts | 19 + src/gen/chat/ChatApi.ts | 88 +++ src/gen/models/index.ts | 855 +++++++++++++++++++++-- test/docs-snippets/docs-snippets-todo.md | 6 +- 4 files changed, 890 insertions(+), 78 deletions(-) diff --git a/src/gen/chat/ChannelApi.ts b/src/gen/chat/ChannelApi.ts index 9ce44d7702..07748ba6c5 100644 --- a/src/gen/chat/ChannelApi.ts +++ b/src/gen/chat/ChannelApi.ts @@ -15,6 +15,8 @@ import type { MarkReadRequest, MarkReadResponse, MarkUnreadRequest, + QueryChannelBansRequest, + QueryChannelBansResponse, Response, SendEventRequest, SendMessageRequest, @@ -121,6 +123,23 @@ export class ChannelApi { ); } + queryChannelBans( + request?: QueryChannelBansRequest, + requestOptions?: StreamRequestOptions, + ): 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, + requestOptions, + ); + } + deleteDraft( request?: { parent_id?: string }, requestOptions?: StreamRequestOptions, diff --git a/src/gen/chat/ChatApi.ts b/src/gen/chat/ChatApi.ts index d96aaf1387..c7217046c3 100644 --- a/src/gen/chat/ChatApi.ts +++ b/src/gen/chat/ChatApi.ts @@ -27,6 +27,7 @@ import type { DeleteMessageResponse, DeleteReactionResponse, DeleteReminderResponse, + DeleteScheduledMessageResponse, EventResponse, FileUploadRequest, FileUploadResponse, @@ -69,6 +70,8 @@ import type { PollVotesResponse, QueryBannedUsersPayload, QueryBannedUsersResponse, + QueryChannelBansRequest, + QueryChannelBansResponse, QueryChannelsRequest, QueryChannelsResponse, QueryDraftsRequest, @@ -85,6 +88,8 @@ import type { QueryReactionsResponse, QueryRemindersRequest, QueryRemindersResponse, + QueryScheduledMessagesRequest, + QueryScheduledMessagesResponse, QueryThreadsRequest, QueryThreadsResponse, QueryUsersPayload, @@ -101,6 +106,8 @@ import type { SendMessageResponse, SendReactionRequest, SendReactionResponse, + SendScheduledMessageNowRequest, + SendScheduledMessageNowResponse, SharedLocationResponse, SharedLocationsResponse, ShowChannelRequest, @@ -134,6 +141,8 @@ import type { UpdatePollRequest, UpdateReminderRequest, UpdateReminderResponse, + UpdateScheduledMessageRequest, + UpdateScheduledMessageResponse, UpdateThreadPartialRequest, UpdateThreadPartialResponse, UpdateUserGroupRequest, @@ -421,6 +430,22 @@ export class ChatApi { ); } + queryChannelBans( + pathParams: { type: string; id: string }, + request?: QueryChannelBansRequest, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'POST', + '/api/v2/chat/channels/{type}/{id}/bans/query', + pathParams, + undefined, + request ?? {}, + 'application/json', + requestOptions, + ); + } + deleteDraft( pathParams: { type: string; id: string }, request?: { parent_id?: string }, @@ -1131,6 +1156,69 @@ export class ChatApi { ); } + queryScheduledMessages( + request?: QueryScheduledMessagesRequest, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'POST', + '/api/v2/chat/scheduled_messages/query', + undefined, + undefined, + request ?? {}, + 'application/json', + requestOptions, + ); + } + + deleteScheduledMessage( + pathParams: { id: string }, + _request?: Record, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'DELETE', + '/api/v2/chat/scheduled_messages/{id}', + pathParams, + undefined, + undefined, + undefined, + requestOptions, + ); + } + + updateScheduledMessage( + pathParams: { id: string }, + request?: UpdateScheduledMessageRequest, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'PATCH', + '/api/v2/chat/scheduled_messages/{id}', + pathParams, + undefined, + request ?? {}, + 'application/json', + requestOptions, + ); + } + + sendScheduledMessageNow( + pathParams: { id: string }, + request?: SendScheduledMessageNowRequest, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'POST', + '/api/v2/chat/scheduled_messages/{id}/send_now', + pathParams, + undefined, + request ?? {}, + 'application/json', + requestOptions, + ); + } + search( request?: { payload?: SearchPayload }, requestOptions?: StreamRequestOptions, diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index b328747f3b..934eb582c3 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -191,6 +191,7 @@ export interface AIIndicatorClearEvent { */ cid?: string; received_at?: TimestampNS; + user?: UserResponseCommonFields; } export interface AIIndicatorStopEvent { @@ -216,6 +217,7 @@ export interface AIIndicatorStopEvent { */ cid?: string; received_at?: TimestampNS; + user?: UserResponseCommonFields; } export interface AIIndicatorUpdateEvent { @@ -253,6 +255,7 @@ export interface AIIndicatorUpdateEvent { */ cid?: string; received_at?: TimestampNS; + user?: UserResponseCommonFields; } export interface AITextConfig { @@ -300,6 +303,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 */ @@ -397,6 +404,17 @@ export interface AddUserGroupMembersResponse { user_group?: UserGroupResponse; } +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 AppEventResponse { /** * boolean @@ -429,6 +447,10 @@ export interface AppResponseFields { */ name: string; placement: string; + /** + * Request limit defaults applied by the server when the corresponding field is omitted. + */ + defaults: AppDefaults; file_upload_config: FileUploadConfig; image_upload_config: FileUploadConfig; } @@ -723,11 +745,19 @@ export interface BanRequest { * ID of the user to ban */ target_user_id: string; + /** + * Whether to also ban the user from future channels created by the banner + */ + ban_from_future_channels?: boolean; /** * Channel where the ban applies */ channel_cid?: string; delete_messages?: 'soft' | 'pruning' | 'hard'; + /** + * Whether to delete reactions by the banned user on other users' messages + */ + delete_reactions?: boolean; /** * Whether to ban the user's IP address */ @@ -1036,6 +1066,33 @@ export interface CastPollVoteRequest { vote?: VoteData; } +export interface ChannelBanResponse { + created_at: TimestampNS; + scopes: Array; + channel_ban?: ChannelBanScopeResponse; + future_channel_ban?: ChannelBanScopeResponse; + /** + * User response object + */ + user?: UserResponse; +} + +export interface ChannelBanScopeResponse { + created_at: TimestampNS; + shadow: boolean; + expires?: TimestampNS; + reason?: string; + /** + * User response object + */ + banned_by?: UserResponse; +} + +export interface ChannelBansScopeTotals { + channel: number; + future_channel: number; +} + export interface ChannelConfigOverrides { blocklist?: string; blocklist_behavior?: 'flag' | 'block'; @@ -1267,6 +1324,10 @@ export interface ChannelGetOrCreateRequest { * Whether this channel will be hidden for the user who created the channel or not */ hide_for_creator?: boolean; + /** + * Adds scheduled_messages to the response: the caller's own scheduled, sending and failed messages in this channel. Client-side only. Absent from the response when there are more than 100 or they could not be loaded; use the scheduled messages query then + */ + include_scheduled_messages?: boolean; /** * Fetch user presence info */ @@ -1469,6 +1530,7 @@ export interface ChannelMemberResponse { * Date/time of the last update */ updated_at: TimestampNS; + user_id: string; custom: CustomMemberData; archived_at?: TimestampNS; /** @@ -1506,7 +1568,6 @@ export interface ChannelMemberResponse { */ role?: string; status?: string; - user_id?: string; deleted_messages?: Array; /** * User response object @@ -1577,6 +1638,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', @@ -1725,16 +1787,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?: TimestampNS; + /** + * 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 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 @@ -1762,6 +1862,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 */ @@ -2331,6 +2435,11 @@ export interface ContentCountRuleParameters { time_window?: string; } +export interface ContentCountryCodeParameters { + operator?: string; + country_codes?: Array; +} + export interface ContentCustomPropertyCountParameters { operator?: string; property_key?: string; @@ -2681,6 +2790,31 @@ export interface DeleteModerationConfigResponse { 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 {} export interface DeleteReactionRequestPayload { @@ -2721,6 +2855,17 @@ export interface DeleteReminderResponse { 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 DeleteUserMessagesRequestPayload { /** * Message deletion mode: soft, pruning, or hard @@ -3002,7 +3147,6 @@ export interface EntityCreatorResponse { image?: string; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; teams_role?: Record; } @@ -3100,6 +3244,7 @@ export interface FeedsNotificationComment { comment: string; id: string; user_id: string; + parent_id?: string; attachments?: Array; } @@ -3270,6 +3415,7 @@ export interface FeedsV3CommentResponse { confidence_score: number; created_at: TimestampNS; downvote_count: number; + hidden: boolean; id: string; object_id: string; object_type: string; @@ -3513,7 +3659,6 @@ export interface FullUserResponse { image?: string; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; latest_hidden_channels?: Array; /** * The privacy settings of the user. @@ -3660,6 +3805,17 @@ export interface GetOGResponse { giphy?: Images; } +export interface GetOrCreateChannelDefaults { + /** + * Resolved defaults for a nested pagination object. + */ + members: PagerDefaults; + /** + * Resolved defaults for a nested pagination object. + */ + messages: PagerDefaults; +} + export interface GetPinnedMessagesResponse { /** * Duration of the request in milliseconds @@ -3715,15 +3871,38 @@ 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?: Filters<{ + group: { + type: string; + operators: '$eq' | '$in' | '$ne'; + }; + + query_timestamp: { + type: Date | string; + operators: '$gte'; + }; + }>; } export interface GroupedQueryChannelsRequest { @@ -3740,7 +3919,11 @@ export interface GroupedQueryChannelsRequest { */ watch?: boolean; /** - * 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; } @@ -3751,7 +3934,7 @@ export interface GroupedQueryChannelsResponse { */ duration: string; /** - * Predefined channel groups keyed by group name + * Groups keyed by the requested stored group name */ groups: Record; } @@ -3899,66 +4082,31 @@ export interface IntentConfigResponse { topics: Array; } +export interface IntentRuleParameters { + topic: string; + threshold?: number; +} + 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 KeyframeOCRRuleParameters { @@ -4492,6 +4640,9 @@ export interface MessageModerationResult { * User who moderated the message */ moderated_by?: string; + /** + * Response from AI moderation + */ ai_moderation_response?: ModerationResponse; /** * Sets thresholds for AI moderation @@ -5371,9 +5522,21 @@ export interface ModerationQueueResponse { } export interface ModerationResponse { + /** + * Action taken by automod + */ action: string; + /** + * Explicit score + */ explicit: number; + /** + * Spam score + */ spam: number; + /** + * Toxicity score + */ toxic: number; } @@ -5467,7 +5630,20 @@ export interface NotificationAddedToChannelEvent { * The team ID */ team?: string; + /** + * The total number of unread messages + */ + total_unread_count?: number; + /** + * The number of channels with unread messages + */ + unread_channels?: number; + /** + * The number of unread messages + */ + unread_count?: number; channel_custom?: CustomChannelData; + grouped_unread_channels?: Record; } export interface NotificationChannelDeletedEvent { @@ -6076,6 +6252,7 @@ export interface OwnUserResponse { unread_count: number; unread_threads: number; updated_at: TimestampNS; + blocked_user_ids: Array; channel_mutes: Array; devices: Array; mutes: Array; @@ -6087,8 +6264,6 @@ export interface OwnUserResponse { image?: string; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; - blocked_user_ids?: Array; latest_hidden_channels?: Array; /** * The privacy settings of the user. @@ -6102,6 +6277,13 @@ export interface OwnUserResponse { total_unread_count_by_team?: Record; } +export interface PagerDefaults { + /** + * Number of records returned when `limit` is omitted. + */ + limit: number; +} + export interface PaginationParams { id_gt?: number; id_gte?: number; @@ -6456,12 +6638,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 { @@ -6477,6 +6667,32 @@ 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 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 QueryAppealsRequest { @@ -6596,10 +6812,10 @@ export interface QueryBannedUsersPayload { operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; }; }>; - created_at_after?: Date; - created_at_after_or_equal?: Date; - created_at_before?: Date; - created_at_before_or_equal?: Date; + created_at_after?: TimestampNS; + created_at_after_or_equal?: TimestampNS; + created_at_before?: TimestampNS; + created_at_before_or_equal?: TimestampNS; /** * Whether to exclude expired bans or not */ @@ -6629,6 +6845,66 @@ export interface QueryBannedUsersResponse { bans: Array; } +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; + /** + * Filter conditions to apply to the query + */ + filter_conditions?: Filters<{ + user_id: { + type: string; + operators: '$autocomplete' | '$eq' | '$in'; + }; + }>; +} + +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 { /** * Number of channels to limit @@ -6823,10 +7099,6 @@ export interface QueryChannelsRequest { operators: '$eq'; }; - /** - * With multi-tenancy, client-side queries without a `team` filter only return channels of - * the user's teams; an empty filter (`team: {}`) queries across all teams. - */ team: { type: string; operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; @@ -6905,10 +7177,10 @@ export interface QueryDraftsResponse { } export interface QueryFutureChannelBansPayload { - created_at_after?: Date; - created_at_after_or_equal?: Date; - created_at_before?: Date; - created_at_before_or_equal?: Date; + created_at_after?: TimestampNS; + created_at_after_or_equal?: TimestampNS; + created_at_before?: TimestampNS; + created_at_before_or_equal?: TimestampNS; /** * Whether to exclude expired bans or not */ @@ -6948,10 +7220,10 @@ export interface QueryFutureChannelBansResponse { export interface QueryMembersPayload { type: string; - created_at_after?: Date; - created_at_after_or_equal?: Date; - created_at_before?: Date; - created_at_before_or_equal?: Date; + created_at_after?: TimestampNS; + created_at_after_or_equal?: TimestampNS; + created_at_before?: TimestampNS; + created_at_before_or_equal?: TimestampNS; id?: string; limit?: number; offset?: number; @@ -7551,6 +7823,11 @@ export interface QueryReviewQueueRequest { operators: '$eq' | '$in' | '$q'; }; + country_code: { + type: string; + operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; + }; + created_at: { type: Date | string; operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; @@ -7761,6 +8038,40 @@ export interface QueryReviewQueueResponse { filter_config?: FilterConfigResponse; } +export interface QueryScheduledMessagesRequest { + /** + * Page size, 1 to 100. Defaults to 20 + */ + limit?: number; + /** + * Cursor from the previous page + */ + next?: string; + /** + * Only scheduled_at ascending is supported. Pending messages come before failed ones + */ + sort?: Array; + /** + * Filter for querying scheduled messages + */ + filter?: ScheduledMessagesFilter; +} + +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 QueryThreadsRequest { limit?: number; member_limit?: number; @@ -8010,6 +8321,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 Reaction { activity_id: string; created_at: TimestampNS; @@ -8539,6 +8880,7 @@ export interface ReviewQueueItemResponse { */ confidence_score?: number; config_key?: string; + country_code?: string; /** * ID of who created the entity */ @@ -8577,6 +8919,7 @@ export interface ReviewQueueItemResponse { * Content payload for moderation */ moderation_payload?: ModerationPayloadResponse; + poll?: PollResponseData; reaction?: Reaction; } @@ -8638,6 +8981,7 @@ export interface RuleBuilderCondition { channel_message_count_rule_params?: ChannelMessageCountRuleParameters; closed_caption_rule_params?: ClosedCaptionRuleParameters; content_count_rule_params?: ContentCountRuleParameters; + content_country_code_params?: ContentCountryCodeParameters; content_custom_property_count_params?: ContentCustomPropertyCountParameters; content_custom_property_params?: ContentCustomPropertyParameters; content_flag_count_rule_params?: FlagCountRuleParameters; @@ -8645,6 +8989,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; @@ -8652,6 +9000,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; @@ -8686,6 +9035,294 @@ export interface RuleBuilderRule { action?: RuleBuilderAction; } +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 { + /** + * Text of the message + */ + text: string; + custom: Record; + /** + * Contains HTML markup of the message + */ + html?: string; + /** + * Whether the message mentions the whole channel + */ + mentioned_channel?: boolean; + /** + * Whether the message mentions the online channel members + */ + mentioned_here?: boolean; + /** + * MML content of the message + */ + mml?: string; + /** + * ID of parent message (thread) + */ + parent_id?: string; + /** + * Date/time the pin expires + */ + pin_expires?: TimestampNS; + /** + * Whether the message will be pinned when sent + */ + pinned?: boolean; + /** + * Identifier of the poll to include in the message + */ + poll_id?: string; + quoted_message_id?: string; + /** + * Whether thread reply should be shown in the channel as well + */ + show_in_channel?: boolean; + /** + * Whether message is silent or not + */ + silent?: boolean; + /** + * Contains type of the message. One of: regular, system + */ + type?: string; + /** + * Array of message attachments + */ + attachments?: Array; + /** + * IDs of the user groups mentioned in the message + */ + mentioned_group_ids?: Array; + /** + * Roles mentioned in the message + */ + mentioned_roles?: Array; + /** + * IDs of the users mentioned in the message + */ + mentioned_users?: Array; + /** + * IDs of the users the message will be visible to + */ + restricted_visibility?: Array; + /** + * The location a scheduled message will share + */ + shared_location?: ScheduledMessageLocation; +} + +export interface ScheduledMessageCreatedEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.created" in this case + */ + type: string; + /** + * The CID of the channel the message is scheduled in + */ + cid?: string; + received_at?: TimestampNS; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessageDeletedEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.deleted" in this case + */ + type: string; + /** + * The CID of the channel the message is scheduled in + */ + cid?: string; + received_at?: TimestampNS; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessageFailedEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.failed" in this case + */ + type: string; + /** + * The CID of the channel the message is scheduled in + */ + cid?: string; + received_at?: TimestampNS; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessageLocation { + /** + * The ID of the device that created the location. + */ + created_by_device_id: string; + /** + * The date when the location sharing ends. + * If it's empty, it means the location sharing is static instead of live. + */ + end_at?: TimestampNS; + /** + * The latitude of the location. + */ + latitude?: number; + /** + * The longitude of the location. + */ + longitude?: number; +} + +export interface ScheduledMessageResponse { + /** + * The CID of the channel the message will be sent to + */ + cid: string; + /** + * Date/time of creation + */ + created_at: TimestampNS; + /** + * Unique identifier of the scheduled message + */ + id: string; + /** + * Date/time the message is scheduled to be sent + */ + scheduled_at: TimestampNS; + /** + * 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 + */ + status: string; + /** + * Date/time of the last update + */ + updated_at: TimestampNS; + /** + * The message a scheduled message will send + */ + message: ScheduledMessageContentResponse; + /** + * User response object + */ + user: UserResponse; + /** + * 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 + */ + failure_reason?: string; + /** + * The channel of a scheduled message + */ + channel?: ScheduledMessageChannelResponse; +} + +export interface ScheduledMessageSentEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + /** + * 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?: TimestampNS; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessageUpdatedEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.updated" in this case + */ + type: string; + /** + * The CID of the channel the message is scheduled in + */ + cid?: string; + received_at?: TimestampNS; + /** + * 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 SearchPayload { /** * Channel filter conditions @@ -8843,10 +9480,6 @@ export interface SearchPayload { operators: '$eq'; }; - /** - * With multi-tenancy, client-side queries without a `team` filter only return channels of - * the user's teams; an empty filter (`team: {}`) queries across all teams. - */ team: { type: string; operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; @@ -9120,6 +9753,10 @@ export interface SendMessageRequest { */ include_mentioned_members?: boolean; keep_channel_hidden?: 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; } @@ -9141,6 +9778,10 @@ export interface SendMessageResponse { * Map of mentioned user ID to whether that user is currently an active channel member. Only set when include_mentioned_members was requested; omitted when the message has no mentions or the membership lookup failed */ mentioned_members?: Record; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; } export interface SendReactionRequest { @@ -9170,6 +9811,19 @@ export interface SendReactionResponse { reaction: ReactionResponse; } +export interface SendScheduledMessageNowRequest {} + +export interface SendScheduledMessageNowResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * A message scheduled to be sent later + */ + scheduled_message: ScheduledMessageResponse; +} + export interface ShadowBlockActionRequestPayload { /** * Reason for shadow blocking @@ -9311,7 +9965,7 @@ export interface SortParamRequest { 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' @@ -9320,6 +9974,9 @@ export interface SubmitActionRequest { | 'delete_activity' | 'delete_comment' | 'delete_reaction' + | 'delete_poll' + | 'delete_poll_option' + | 'delete_poll_answer' | 'ban' | 'custom' | 'unban' @@ -9369,6 +10026,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 */ @@ -10241,6 +10910,28 @@ export interface UpdateReminderResponse { reminder: ReminderResponseData; } +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 UpdateThreadPartialRequest { /** * Array of field names to unset @@ -10532,6 +11223,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; @@ -10911,10 +11608,6 @@ export interface UserResponse { * Optional name of user */ name?: string; - /** - * Revocation date for tokens - */ - revoke_tokens_issued_before?: TimestampNS; teams_role?: Record; } @@ -10935,7 +11628,6 @@ export interface UserResponseCommonFields { image?: string; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; teams_role?: Record; } @@ -10957,7 +11649,6 @@ export interface UserResponsePrivacyFields { invisible?: boolean; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; /** * The privacy settings of the user. */ @@ -11145,6 +11836,10 @@ export interface WSAuthMessage { */ token: string; user_details: ConnectUserDetailsRequest; + /** + * Realtime event protocol version. 3 omits channel.members from channel.updated, member.*, notification.message_new and notification.added_to_channel of channels with more than 100 members and sets members_included:false. Values above the newest supported version negotiate down to it. + */ + event_protocol?: number; /** * Channel-member custom keys to project onto message.member for messages this connection receives (opt-in; capped, off by default). */ @@ -11215,6 +11910,11 @@ export type WSClientEvent = | ({ type: 'reminder.created' } & ReminderCreatedEvent) | ({ type: 'reminder.deleted' } & ReminderDeletedEvent) | ({ type: 'reminder.updated' } & ReminderUpdatedEvent) + | ({ type: 'scheduled_message.created' } & ScheduledMessageCreatedEvent) + | ({ type: 'scheduled_message.deleted' } & ScheduledMessageDeletedEvent) + | ({ type: 'scheduled_message.failed' } & ScheduledMessageFailedEvent) + | ({ type: 'scheduled_message.sent' } & ScheduledMessageSentEvent) + | ({ type: 'scheduled_message.updated' } & ScheduledMessageUpdatedEvent) | ({ type: 'thread.updated' } & ThreadUpdatedEvent) | ({ type: 'typing.start' } & TypingStartEvent) | ({ type: 'typing.stop' } & TypingStopEvent) @@ -11297,6 +11997,11 @@ export type WSEvent = | ({ type: 'reminder.created' } & ReminderCreatedEvent) | ({ type: 'reminder.deleted' } & ReminderDeletedEvent) | ({ type: 'reminder.updated' } & ReminderUpdatedEvent) + | ({ type: 'scheduled_message.created' } & ScheduledMessageCreatedEvent) + | ({ type: 'scheduled_message.deleted' } & ScheduledMessageDeletedEvent) + | ({ type: 'scheduled_message.failed' } & ScheduledMessageFailedEvent) + | ({ type: 'scheduled_message.sent' } & ScheduledMessageSentEvent) + | ({ type: 'scheduled_message.updated' } & ScheduledMessageUpdatedEvent) | ({ type: 'thread.updated' } & ThreadUpdatedEvent) | ({ type: 'typing.start' } & TypingStartEvent) | ({ type: 'typing.stop' } & TypingStopEvent) diff --git a/test/docs-snippets/docs-snippets-todo.md b/test/docs-snippets/docs-snippets-todo.md index ff788d23cd..159c8c84eb 100644 --- a/test/docs-snippets/docs-snippets-todo.md +++ b/test/docs-snippets/docs-snippets-todo.md @@ -4,6 +4,6 @@ Found while migrating the snippets to v10 and patched by hand in `src/gen/models/index.ts`. A `yarn generate-client` overwrites the patches, so fix them at the source, regenerate, and check that `yarn types`, `yarn types:tests` and the docs tests still pass. -- [ ] **Object-valued custom fields in filters** (`generator/templates/templates/typescript/models.tpl`, `CustomHelper`): a typed `custom.` key is built for every custom data field, including object-valued ones (e.g. `CustomChannelData.source_detail: { user_id: number }`). Its value doesn't fit the `custom.${string}` index signature (`string | boolean | number | Date`), so every filter type built from the custom data stops being assignable, including the SDK's own search sources (`ChannelSearchSource`, `MessageSearchSource`). Fix: only emit typed keys for primitive and `Date` fields (`NonNullable extends string | boolean | number | Date`); object fields fall back to the index signature, which already accepts nested paths like `custom.source_detail.user_id`. Covered by `test/types/custom-filters.ts`. -- [ ] **Request dates typed as response dates** (OpenAPI spec): `created_at_after`, `created_at_after_or_equal`, `created_at_before` and `created_at_before_or_equal` on `QueryMembersPayload`, `QueryBannedUsersPayload` and `QueryFutureChannelBansPayload` are typed `TimestampNS` (the spec types them as numbers, the server-sent direction). They are request fields, sent in the JSON `payload` query param, and the server parses them as RFC 3339: a number fails with `Time.UnmarshalJSON: input is not a JSON string` (`03-channels/04-query_members`, "Paginate by created_at"). Fix the spec so they are request (string) dates, which the generator types as `Date`. Also check `ChannelStopWatchingRequest` (`created_at`, `received_at` typed `TimestampNS`), not investigated. -- [ ] **Empty `team` channel filter** (OpenAPI spec + `models.tpl`, `Filters`): with multi-tenancy, `team: {}` queries channels across all teams (`platform/02-identity-and-access/04-multi-tenancy`, "Querying chat channels"), but every filter key only accepted an operator object with exactly one operator. Patched by hand: an `allowEmpty: true` flag on the `team` entries of `QueryChannelsRequest` and `SearchPayload` (both are `ChannelFilters`, and `MessageSearchSource` assigns one to the other), which `Filters` turns into an extra `Record` member. Fix: have the spec mark the key and the template emit the flag and the `Filters` branch. Covered by `test/types/team-filter.ts`. +- [ ] **Object-valued custom fields in filters** (`generator/templates/templates/typescript/models.tpl`, `CustomHelper`): a typed `custom.` key is built for every custom data field, including object-valued ones (e.g. `CustomChannelData.source_detail: { user_id: number }`). Its value doesn't fit the `custom.${string}` index signature (`string | boolean | number | Date`), so every filter type built from the custom data stops being assignable, including the SDK's own search sources (`ChannelSearchSource`, `MessageSearchSource`). Fix: only emit typed keys for primitive and `Date` fields (`NonNullable extends string | boolean | number | Date`); object fields fall back to the index signature, which already accepts nested paths like `custom.source_detail.user_id`. Covered by `test/types/custom-filters.ts`. **Open PR**: https://github.com/GetStream/chat/pull/18709 +- [ ] **Request dates typed as response dates** (OpenAPI spec): `created_at_after`, `created_at_after_or_equal`, `created_at_before` and `created_at_before_or_equal` on `QueryMembersPayload`, `QueryBannedUsersPayload` and `QueryFutureChannelBansPayload` are typed `TimestampNS` (the spec types them as numbers, the server-sent direction). They are request fields, sent in the JSON `payload` query param, and the server parses them as RFC 3339: a number fails with `Time.UnmarshalJSON: input is not a JSON string` (`03-channels/04-query_members`, "Paginate by created_at"). Fix the spec so they are request (string) dates, which the generator types as `Date`. Also check `ChannelStopWatchingRequest` (`created_at`, `received_at` typed `TimestampNS`), not investigated. **Reported here**: https://getstream.slack.com/archives/C07AL9Q0T2T/p1791987002057839?thread_ts=1791403182.844109&cid=C07AL9Q0T2T +- [ ] **Empty `team` channel filter** (OpenAPI spec + `models.tpl`, `Filters`): with multi-tenancy, `team: {}` queries channels across all teams (`platform/02-identity-and-access/04-multi-tenancy`, "Querying chat channels"), but every filter key only accepted an operator object with exactly one operator. Patched by hand: an `allowEmpty: true` flag on the `team` entries of `QueryChannelsRequest` and `SearchPayload` (both are `ChannelFilters`, and `MessageSearchSource` assigns one to the other), which `Filters` turns into an extra `Record` member. Fix: have the spec mark the key and the template emit the flag and the `Filters` branch. Covered by `test/types/team-filter.ts`. **Open PR:**: https://github.com/GetStream/chat/pull/18709 From 68d9d8f54056950ed2b0997ac800f93f83888a12 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 15 Oct 2026 15:01:07 -0500 Subject: [PATCH 09/10] chore: update migration guide --- v9-to-v10-migration-guide-methods.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index 57aff64ec8..72821d0c6e 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -941,12 +941,15 @@ channel.truncate(request?: TruncateChannelRequest); channel.acceptInvite(options?: UpdateChannelOptions); channel.rejectInvite(options?: UpdateChannelOptions); -// v10 — options type renamed -channel.acceptInvite(options?: ChannelUpdateOptions); -channel.rejectInvite(options?: ChannelUpdateOptions); +// v10 — options typed as the generated request +channel.acceptInvite(options?: UpdateChannelRequest, requestOptions?); +channel.rejectInvite(options?: UpdateChannelRequest, requestOptions?); ``` -`ChannelUpdateOptions` = `Omit`. +`options` is the full generated `UpdateChannelRequest`, so it still carries `message` (the system message +sent with the accept / reject), as v9's `UpdateChannelOptions` did. The other membership helpers +(`addMembers`, `inviteMembers`, …) take `ChannelUpdateOptions` (`Omit`), +because they take `message` as a separate positional argument. #### `channel.mute` / `channel.unmute` From 76752be36aaa493527d255153f3a9bd94314201c Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 15 Oct 2026 15:05:32 -0500 Subject: [PATCH 10/10] chore: more migration guide update --- v9-to-v10-migration-guide-methods.md | 18 +++++++++-- v9-to-v10-migration-guide-other.md | 45 ++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index 72821d0c6e..ee71d840cf 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -453,6 +453,20 @@ The SDK itself gets channels through `client.channelManager.ensure()`, so a spy sees only your own calls; spy on `client.channelManager.ensure` (called with `{ type, id, data }`) to see every channel the client gets. +#### `client.setGuestUser` + +```ts +// v9 +client.setGuestUser(user: UserResponse); + +// v10 +client.setGuestUser(user: UserRequest, requestOptions?); +``` + +`user` is the generated `UserRequest` (`id`, plus optional `name`, `image`, `language`, `invisible`, +`privacy_settings` and `custom`), the shape `createGuest` sends. App custom fields go under `custom` +rather than at the root. + #### `client.setAnonymousUser` alias ```ts @@ -474,7 +488,7 @@ client.sendFile(url, uri, name?, contentType?, user?, axiosRequestConfig?); // v10 client.api.doAxiosRequest(type, url, data?, options?); -client.dispatchEvent(event: Event); // Event union expanded to WSEvent | LocalEvent | keyof CustomEventTypes +client.dispatchEvent(event: Event); // Event union is now WSEvent | LocalEvent | CustomEventTypeUnion client.api.errorFromResponse(response); // moved to ApiClient // client.sendFile — REMOVED, and there is no `client.api.sendFile` either. Multipart encoding // now lives in `ApiClient.sendRequest`; use the upload methods below. @@ -1258,7 +1272,7 @@ Signatures unchanged. #### `channel._handleChannelEvent` / `channel._callChannelListeners` -Both still take `Event` — the union shape of `Event` itself changed (now `WSEvent | LocalEvent | keyof CustomEventTypes`), but the parameter type name did not. +Both still take `Event` — the union shape of `Event` itself changed (now `WSEvent | LocalEvent | CustomEventTypeUnion`), but the parameter type name did not. --- diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 6e2ba470d0..9cbb2d8b77 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -37,11 +37,11 @@ managers warn rather than fail. But 18 and 20 are no longer tested. See the note below on what this means if you deploy the WebSocket client on Node 18 or 20. - **Server-side is gone.** If you construct with a `secret` or call server-only admin endpoints, switch to `@stream-io/node-sdk`. The construction guide has the full list — every feature module below that was server-only is dropped for the same reason. -- Two barrels removed from the package root, one added: **`./events` and `./base64` are gone; `./logger` is new.** `./signing` survives with exactly one export left, `UserFromToken`. The `./campaign`, `./channel_batch_updater`, and `./segment` barrels are still exported but the modules are emptied (they contain only a comment pointing at the server SDK) — importing anything by name from them will fail. +- Two barrels removed from the package root, one added: **`./events` and `./base64` are gone; `./logger` is new.** `./signing` survives with two exports left, `UserFromToken` and `DevToken`. The `./campaign`, `./channel_batch_updater`, and `./segment` barrels are still exported but the modules are emptied (they contain only a comment pointing at the server SDK) — importing anything by name from them will fail. - **`connection.changed` is removed.** Connectivity is published as two reactive stores, `client.wsConnection.state` for this client's socket (or its long-poll, once `enableWSFallback` has switched to it) and `client.networkConnection.state` for the device's network. A handler for the event simply stops firing, with no compile error in plain JavaScript, and a "connection lost" banner has to hold a drop itself where the event used to. The socket's own `isHealthy` is unchanged; what moved is where you read it. See below. - **Watching waits instead of degrading.** A request that watches a channel or subscribes to presence is held until the WebSocket handshake produces the connection id the server keys that subscription by, rather than being sent without one and silently registering nothing. It throws only when no socket is open and none is being opened. An explicit `watch: false` is never held. See below. - **The WebSocket connect endpoint moved to `/api/v2/connect`.** The hello event is now `connection.ok` rather than `health.check`. The long-poll fallback works as in v9, against `/api/v2/longpoll`, with `enableWSFallback` moved into the `wsConnection` configuration and `client.defaultWSTimeoutWithFallback` gone. -- `Event` (type name) is kept, but its shape widened: `Event = (WSEvent & { received_at?: TimestampNS }) | LocalEvent | keyof CustomEventTypes`. `EventPayload<''>` narrows to a specific event. +- `Event` (type name) is kept, but its shape changed: `Event = (WSEvent & { received_at?: TimestampNS }) | LocalEvent | CustomEventTypeUnion`, where each key augmented into `CustomEventTypes` becomes an event object whose data sits under `custom` (typed by `CustomEventData`) instead of at the root. `EventPayload<''>` narrows to a specific event. - `EventTypes` (plural) renamed to `EventType` (singular). `CustomEventTypes` interface is unchanged — augment it to add custom event-type keys, same as v9. - Filter payloads now carry **per-endpoint operator constraints** (inline `Filters<{ … }>` on each request type) — previously-permissive filter objects may stop type-checking. Only one operator per field is allowed, and `null` is no longer a valid `$in` element. `QueryPollsFilters`, `QueryVotesFilters`, and `ReminderFilters` were the last hand-written holdouts and now derive from their request types too. - `ChannelState.membership` initializes to `undefined` (was `{}`); `ChannelState.typing` values are now `EventPayload<'typing.start' | 'typing.stop'>` (were `Event`); read receipts merged with the generated `ReadStateResponse`. @@ -241,7 +241,14 @@ type LocalEvent = ( export type Event = | (WSEvent & { received_at?: number }) | LocalEvent - | keyof CustomEventTypes; + | CustomEventTypeUnion; + +// One event object per key augmented into `CustomEventTypes`; `never` when none are declared. +type CustomEventTypeUnion = { + [K in keyof CustomEventTypes]: { type: K } & Omit & { + received_at?: TimestampNS; + }; +}[keyof CustomEventTypes]; export type EventType = Event['type'] | 'all'; export type EventHandler = (event: Extract) => void; @@ -255,7 +262,7 @@ export type EventPayload = Extract< | v9 | v10 | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `import { Event } from 'stream-chat'` | still `import { Event } from 'stream-chat'` — the alias is retained; the union it resolves to widened to `WSEvent \| LocalEvent \| keyof CustomEventTypes`. | +| `import { Event } from 'stream-chat'` | still `import { Event } from 'stream-chat'` — the alias is retained; the union it resolves to is now `WSEvent \| LocalEvent \| CustomEventTypeUnion`. | | `Event` as a callback argument type | `Event` still works; prefer `EventPayload<'message.new'>` for narrowed events. | | `import { EventTypes } from 'stream-chat'` | `import { EventType } from 'stream-chat'` — singular; same shape (`Event['type'] \| 'all'`) | | `import { EVENT_MAP } from 'stream-chat'` | removed — no runtime table. Match on `event.type` directly. | @@ -294,6 +301,32 @@ declare module 'stream-chat' { Because the v10 generic on `channel.on` accepts any `string`, unknown listener keys still type-check without augmentation, but the event payload will not be narrowed. Augmenting `CustomEventTypes` adds the custom key to `Event['type']`, which flows through `EventType` and `EventHandler` narrowing. +What changed is where a custom event's data lives. v9's `Event` spread `CustomEventData` over its root; +in v10 a custom event is the generated `CustomEvent` shape (`type`, `created_at`, `custom`), and its data +is under `custom`, typed by `CustomEventData`. That is also where the API reads it when you send one — +top-level fields other than `type` and `parent_id` are dropped: + +```ts +declare module 'stream-chat' { + interface CustomEventTypes { + friendship_request: true; + } + interface CustomEventData { + text?: string; + } +} + +// v9 +await channel.sendEvent({ type: 'friendship_request', text: 'hi' }); +client.on('friendship_request', (event) => event.text); + +// v10 +await channel.sendEvent({ + event: { type: 'friendship_request', custom: { text: 'hi' } }, +}); +client.on('friendship_request', (event) => event.custom.text); +``` + > **Larger topic** — the event system rewrite (removed hand-rolled event types across `poll`, `poll_manager`, `thread`, `reminders`, live-location, and the client itself; the shift from a hand-maintained `EVENT_MAP` to generated decoders) touches enough call sites that it may warrant a dedicated guide. Flag me if you want one written. --- @@ -967,7 +1000,9 @@ query that lands before `activate()` reuses it rather than creating a second one has only its parent message, so it starts stale and loads its thread data once, when it is first activated. `client.threads.get(id)` only looks a thread up, and `client.threads.paginator.getItem(id)` answers -whether the list holds it. +whether the list holds it. `client.queryThreadsAndHydrate()` and `client.getThreadAndHydrate()` also +return the registered instance for a thread `client.threads` already holds, rather than a second +`Thread` for the same id, so the instance you get is the one that receives events. A thread no longer listens to the client itself: `client.threads` hands each thread event to the threads it holds, the way `client.polls` does for polls. A thread therefore gets events only once