mirror of
https://github.com/immich-app/discord-bot.git
synced 2026-09-30 13:32:52 +08:00
feat: typed zulip client on openapi-fetch (#636)
This commit is contained in:
@@ -13,3 +13,6 @@ package-lock.json
|
||||
yarn.lock
|
||||
|
||||
src/schema/migrations
|
||||
|
||||
# generated
|
||||
src/generated
|
||||
|
||||
@@ -87,6 +87,21 @@ Rules that keep the seam clean:
|
||||
|
||||
Nothing in `webhook.service.ts` or `schedule.service.ts` should change.
|
||||
|
||||
### Zulip
|
||||
|
||||
The bot talks to Zulip through a typed `openapi-fetch` client, not an SDK.
|
||||
|
||||
- **Generated types**: `src/generated/zulip.ts` is generated from Zulip's OpenAPI spec and must never be edited by hand; it is excluded from prettier and eslint. Regenerate it with `npm run zulip:types`. That script in `package.json` is the only place the Zulip release tag is pinned; bump it there when the server is upgraded, rerun the script and commit the output.
|
||||
- **Transport**: `src/repositories/zulip.client.ts` builds a `Client<paths>` per identity (`createZulipClient`). Its rules, all covered by `zulip.client.spec.ts`:
|
||||
- Base URL is `${ZULIP_DOMAIN}/api/v1`; a realm ending in `/` or `/api` is normalised.
|
||||
- Every request body is sent `application/x-www-form-urlencoded`, which is what the spec declares for every endpoint we call (`POST /messages`, later `PATCH /messages/{id}` and `POST /register`). Strings go as they are; `number`, `boolean`, arrays and objects are `JSON.stringify`'d; `undefined` is omitted. Query strings follow the same rule, so an array such as `narrow` becomes one JSON value, never repeated keys. A parameter the spec declares as a JSON-encoded *string* (`narrow` and `message_ids` on `GET /messages` are typed `string`) is passed already stringified.
|
||||
- Multipart (`POST /realm/emoji/{emoji_name}`): spread `multipart({ field: file })` into the call. Every part is a `File`, so it carries a filename with an extension and a content type; `fetch` sets the boundary. Do not set `Content-Type` yourself.
|
||||
- Every call rejects with `ZulipApiError` (`status`, `code`, `msg`) on a non-2xx response or a `result: "error"` body, so `data` is always set when a call resolves. Network errors and timeouts reject too; every request has a timeout.
|
||||
- A `429` is retried after the body's `retry-after` (falling back to the `Retry-After` header), with a bounded attempt count and a bounded maximum wait; the retried request re-sends its body. Nothing else is retried: a 429 was not processed, but retrying a 5xx on `POST /messages` could double-post.
|
||||
- Credentials and the `Authorization` header are never logged.
|
||||
- **Two identities**: `ZulipRepository` holds a `bot` client (posts messages) and a `user` client (uploads emoji) because Zulip only lets human accounts upload emoji (`This endpoint does not accept bot requests`). Config keeps `zulip.bot` and `zulip.user` for that reason. Both are created once, in `ZulipService.init` (skipped with the `dev` sentinel keys); calling a repository method before that throws `Zulip client not initialised`.
|
||||
- **Endpoints**: `ZulipRepository` exposes only what the bot uses today (`sendMessage`, which resolves to the new message's `{ id }`, and `createEmote`). Each phase adds only the endpoints it needs, a few lines each thanks to the generated types; do not add unused methods.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
@@ -96,6 +111,7 @@ npm run lint # ESLint
|
||||
npm run format # Prettier check
|
||||
npm run test # Vitest
|
||||
npm run check:all # format + lint + check + test:cov
|
||||
npm run zulip:types # Regenerate src/generated/zulip.ts from the pinned Zulip OpenAPI spec
|
||||
```
|
||||
|
||||
## Key Files
|
||||
@@ -114,3 +130,5 @@ npm run check:all # format + lint + check + test:cov
|
||||
- `src/interfaces/notification.interface.ts` - Platform-neutral `Notification` model (`kind`, `accent`, `author`, `title`, `url`, `body`, `fields`)
|
||||
- `src/services/notification.service.ts` - Destination-to-platform fan-out for notifications
|
||||
- `src/renderers/` - Per-platform `Notification` renderers and the shared accent palette
|
||||
- `src/generated/zulip.ts` - Generated Zulip API types (`npm run zulip:types`), never edited by hand
|
||||
- `src/repositories/zulip.client.ts` - Typed Zulip transport: form/JSON encoding, multipart, errors, 429 retry, timeout
|
||||
|
||||
@@ -21,6 +21,7 @@ export default [
|
||||
'**/pnpm-lock.yaml',
|
||||
'**/package-lock.json',
|
||||
'**/yarn.lock',
|
||||
'src/generated',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
||||
Generated
+256
-193
@@ -32,14 +32,14 @@
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.3",
|
||||
"octokit": "^5.0.5",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"pg": "^8.12.0",
|
||||
"pg-cursor": "^2.14.6",
|
||||
"rss-parser": "^3.13.0",
|
||||
"semver": "^7.6.3",
|
||||
"undici": "^8.0.0",
|
||||
"vite-tsconfig-paths": "^6.0.0",
|
||||
"ws": "^8.21.3",
|
||||
"zulip-js": "^2.0.9"
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -60,6 +60,7 @@
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"eslint": "^10.0.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
@@ -250,15 +251,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
@@ -2034,6 +2026,105 @@
|
||||
"zod": "^3.25.65 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js-replace": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/config": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
|
||||
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core": {
|
||||
"version": "1.34.20",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.20.tgz",
|
||||
"integrity": "sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/ajv": "8.11.2",
|
||||
"@redocly/config": "0.22.0",
|
||||
"colorette": "1.4.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"js-levenshtein": "1.1.6",
|
||||
"js-yaml": "4.3.2",
|
||||
"minimatch": "5.1.9",
|
||||
"pluralize": "8.0.0",
|
||||
"yaml-ast-parser": "0.0.43"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0",
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/js-yaml": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
@@ -3928,6 +4019,16 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
@@ -4063,12 +4164,6 @@
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
@@ -4358,6 +4453,13 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chardet": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz",
|
||||
@@ -4490,17 +4592,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
"node_modules/colorette": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "15.0.0",
|
||||
@@ -4743,15 +4840,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -4958,21 +5046,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -5512,44 +5585,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz",
|
||||
"integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35",
|
||||
"safe-buffer": "^5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -5747,21 +5782,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hashery": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
|
||||
@@ -5821,6 +5841,20 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
@@ -5894,21 +5928,25 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz",
|
||||
"integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -5993,25 +6031,6 @@
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isomorphic-fetch": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
|
||||
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.1",
|
||||
"whatwg-fetch": "^3.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic-form-data": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-form-data/-/isomorphic-form-data-2.0.0.tgz",
|
||||
"integrity": "sha512-TYgVnXWeESVmQSg4GLVbalmQ+B4NPi/H4eWxqALKj63KsUrcu301YDjBqaOw3h+cbak7Na4Xyps3BiptHtxTfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"form-data": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
@@ -6106,6 +6125,16 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/js-levenshtein": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
@@ -6918,26 +6947,6 @@
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
@@ -7042,6 +7051,73 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-fetch": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz",
|
||||
"integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"openapi-typescript-helpers": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/openapi-core": "^1.34.6",
|
||||
"ansi-colors": "^4.1.3",
|
||||
"change-case": "^5.4.4",
|
||||
"parse-json": "^8.3.0",
|
||||
"supports-color": "^10.2.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-typescript": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.x"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript-helpers": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz",
|
||||
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"index-to-position": "^1.1.0",
|
||||
"type-fest": "^4.39.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -8372,12 +8448,6 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@@ -8465,6 +8535,19 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
@@ -8714,6 +8797,13 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js-replace": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
||||
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -8985,12 +9075,6 @@
|
||||
"defaults": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.106.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz",
|
||||
@@ -9128,22 +9212,6 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-fetch": {
|
||||
"version": "3.6.20",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
||||
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -9259,6 +9327,13 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml-ast-parser": {
|
||||
"version": "0.0.43",
|
||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
@@ -9303,18 +9378,6 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zulip-js": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zulip-js/-/zulip-js-2.1.0.tgz",
|
||||
"integrity": "sha512-kLdxzJZ/FvWHBotUJl7LXCHIkShTjy1FUk5HAWfsal1TM+hw0atCZwgasCpvFDBj01y+39ZEZXgjePaie74Xhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.7",
|
||||
"ini": "^5.0.0",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"isomorphic-form-data": "2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -21,6 +21,7 @@
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:cov": "vitest --coverage",
|
||||
"zulip:types": "openapi-typescript https://raw.githubusercontent.com/zulip/zulip/12.3/zerver/openapi/zulip.yaml -o src/generated/zulip.ts",
|
||||
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/discord-bot} -f src/schema/migrations migrations generate -s dist/schema",
|
||||
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/discord-bot} -f src/schema/migrations migrations revert -f dist/schema/migrations"
|
||||
},
|
||||
@@ -48,14 +49,14 @@
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.3",
|
||||
"octokit": "^5.0.5",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"pg": "^8.12.0",
|
||||
"pg-cursor": "^2.14.6",
|
||||
"rss-parser": "^3.13.0",
|
||||
"semver": "^7.6.3",
|
||||
"undici": "^8.0.0",
|
||||
"vite-tsconfig-paths": "^6.0.0",
|
||||
"ws": "^8.21.3",
|
||||
"zulip-js": "^2.0.9"
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -76,6 +77,7 @@
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"eslint": "^10.0.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
|
||||
+23689
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ export type MessagePayload = { stream: string | number; topic?: string; content:
|
||||
|
||||
export interface IZulipInterface {
|
||||
init(config: ZulipConfig): Promise<void>;
|
||||
sendMessage(payload: MessagePayload): Promise<void>;
|
||||
/** Resolves to the new message's ID; rejects on any Zulip error. */
|
||||
sendMessage(payload: MessagePayload): Promise<{ id: number }>;
|
||||
createEmote(name: string, emoteUrl: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { RequestListener, Server, createServer } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import {
|
||||
ZulipApiError,
|
||||
ZulipClientOptions,
|
||||
ZulipFetch,
|
||||
createZulipClient,
|
||||
encodeForm,
|
||||
multipart,
|
||||
toApiUrl,
|
||||
} from 'src/repositories/zulip.client';
|
||||
import { Mock, afterEach, beforeEach, describe, expect, it, vitest } from 'vitest';
|
||||
|
||||
const REALM = 'https://zulip.example.com';
|
||||
const USERNAME = 'bot@example.com';
|
||||
const API_KEY = 'super-secret-api-key';
|
||||
const BASIC = `Basic ${Buffer.from(`${USERNAME}:${API_KEY}`).toString('base64')}`;
|
||||
|
||||
const json = (body: unknown, init: ResponseInit = {}) =>
|
||||
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init });
|
||||
const success = (extra: Record<string, unknown> = {}) => json({ result: 'success', msg: '', ...extra });
|
||||
const failure = (status: number, code: string, msg: string, extra: Record<string, unknown> = {}) =>
|
||||
json({ result: 'error', code, msg, ...extra }, { status });
|
||||
const rateLimited = (retryAfter?: number, headers: Record<string, string> = {}) =>
|
||||
json(
|
||||
{ result: 'error', code: 'RATE_LIMIT_HIT', msg: 'API usage exceeded rate limit', 'retry-after': retryAfter },
|
||||
{ status: 429, headers: { 'content-type': 'application/json', ...headers } },
|
||||
);
|
||||
|
||||
const message = { type: 'channel' as const, to: 54, topic: 'release', content: 'hello world' };
|
||||
|
||||
describe('ZulipClient', () => {
|
||||
let fetchMock: Mock<ZulipFetch>;
|
||||
let sleep: Mock<(ms: number) => Promise<void>>;
|
||||
|
||||
const newClient = (overrides: Partial<ZulipClientOptions> = {}) =>
|
||||
createZulipClient({ realm: REALM, username: USERNAME, apiKey: API_KEY, fetch: fetchMock, sleep, ...overrides });
|
||||
|
||||
/** The request as it left the client on the given attempt. */
|
||||
const request = (attempt = 0) => fetchMock.mock.calls[attempt][0];
|
||||
/** The timeout signal the client handed to fetch, as `init`, on the given attempt. */
|
||||
const signal = (attempt = 0) => fetchMock.mock.calls[attempt][1].signal;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vitest.fn();
|
||||
sleep = vitest.fn().mockResolvedValue(undefined);
|
||||
vitest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vitest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('toApiUrl', () => {
|
||||
it.each([
|
||||
'https://zulip.example.com',
|
||||
'https://zulip.example.com/',
|
||||
'https://zulip.example.com/api',
|
||||
'https://zulip.example.com/api/',
|
||||
])('should normalise %s', (realm) => {
|
||||
expect(toApiUrl(realm)).toBe('https://zulip.example.com/api/v1');
|
||||
});
|
||||
|
||||
it('should send requests to the normalised base URL', async () => {
|
||||
fetchMock.mockResolvedValue(success({ id: 1 }));
|
||||
|
||||
await newClient({ realm: 'https://zulip.example.com/api/' }).POST('/messages', { body: message });
|
||||
|
||||
expect(request().url).toBe('https://zulip.example.com/api/v1/messages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeForm', () => {
|
||||
it('should leave strings as they are', () => {
|
||||
expect(encodeForm({ content: 'hello world & more' })).toBe('content=hello+world+%26+more');
|
||||
});
|
||||
|
||||
it('should JSON-encode numbers', () => {
|
||||
expect(encodeForm({ to: 54 })).toBe('to=54');
|
||||
});
|
||||
|
||||
it('should JSON-encode booleans', () => {
|
||||
expect(encodeForm({ read_by_sender: true, apply_markdown: false })).toBe(
|
||||
'read_by_sender=true&apply_markdown=false',
|
||||
);
|
||||
});
|
||||
|
||||
it('should JSON-encode arrays instead of exploding them', () => {
|
||||
expect(encodeForm({ to: [9, 10] })).toBe('to=%5B9%2C10%5D');
|
||||
expect(encodeForm({ event_types: ['message', 'reaction'] })).toBe(
|
||||
'event_types=%5B%22message%22%2C%22reaction%22%5D',
|
||||
);
|
||||
});
|
||||
|
||||
it('should JSON-encode objects', () => {
|
||||
expect(encodeForm({ client_capabilities: { notification_settings_null: true } })).toBe(
|
||||
'client_capabilities=%7B%22notification_settings_null%22%3Atrue%7D',
|
||||
);
|
||||
});
|
||||
|
||||
it('should omit undefined', () => {
|
||||
expect(encodeForm({ content: 'hi', topic: undefined })).toBe('content=hi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request bodies', () => {
|
||||
it('should send a form-urlencoded body with the string, number, boolean, array and undefined rules', async () => {
|
||||
fetchMock.mockResolvedValue(success({ id: 1 }));
|
||||
|
||||
await newClient().POST('/messages', {
|
||||
body: {
|
||||
type: 'channel',
|
||||
to: [9, 10],
|
||||
topic: 'a topic',
|
||||
content: 'hi there',
|
||||
read_by_sender: true,
|
||||
local_id: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request().method).toBe('POST');
|
||||
expect(request().headers.get('content-type')).toBe('application/x-www-form-urlencoded');
|
||||
expect(await request().text()).toBe(
|
||||
'type=channel&to=%5B9%2C10%5D&topic=a+topic&content=hi+there&read_by_sender=true',
|
||||
);
|
||||
});
|
||||
|
||||
it('should send no content type without a body', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
success({ messages: [], found_anchor: true, found_newest: true, found_oldest: false, history_limited: false }),
|
||||
);
|
||||
|
||||
await newClient().GET('/messages', { params: { query: { anchor: 'newest', num_before: 1, num_after: 0 } } });
|
||||
|
||||
expect(request().method).toBe('GET');
|
||||
expect(request().headers.get('content-type')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query strings', () => {
|
||||
it('should JSON-encode numbers, booleans and arrays instead of exploding them', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
success({ messages: [], found_anchor: true, found_newest: true, found_oldest: false, history_limited: false }),
|
||||
);
|
||||
const narrow = [{ operator: 'channel', operand: 54 }];
|
||||
|
||||
await newClient().GET('/messages', {
|
||||
params: {
|
||||
query: {
|
||||
anchor: 'newest',
|
||||
num_before: 10,
|
||||
num_after: 0,
|
||||
apply_markdown: false,
|
||||
// 12.3 declares `narrow` as a JSON string; the serializer must still encode a raw array as one value.
|
||||
narrow: narrow as unknown as string,
|
||||
message_ids: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { pathname, searchParams } = new URL(request().url);
|
||||
expect(pathname).toBe('/api/v1/messages');
|
||||
expect([...searchParams.keys()]).toEqual(['anchor', 'num_before', 'num_after', 'apply_markdown', 'narrow']);
|
||||
expect(searchParams.get('anchor')).toBe('newest');
|
||||
expect(searchParams.get('num_before')).toBe('10');
|
||||
expect(searchParams.get('apply_markdown')).toBe('false');
|
||||
expect(searchParams.getAll('narrow')).toEqual([JSON.stringify(narrow)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multipart', () => {
|
||||
it('should send a part with a filename and an image content type, and let fetch set the boundary', async () => {
|
||||
fetchMock.mockResolvedValue(success());
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'catjam.png', { type: 'image/png' });
|
||||
|
||||
await newClient().POST('/realm/emoji/{emoji_name}', {
|
||||
params: { path: { emoji_name: 'catjam' } },
|
||||
...multipart({ filename: file }),
|
||||
});
|
||||
|
||||
expect(request().url).toBe('https://zulip.example.com/api/v1/realm/emoji/catjam');
|
||||
expect(request().headers.get('content-type')).toMatch(/^multipart\/form-data; boundary=/);
|
||||
|
||||
const form = await request().formData();
|
||||
expect([...form.keys()]).toEqual(['filename']);
|
||||
const part = form.get('filename') as File;
|
||||
expect(part).toBeInstanceOf(File);
|
||||
expect(part.name).toBe('catjam.png');
|
||||
expect(part.type).toBe('image/png');
|
||||
expect(new Uint8Array(await part.arrayBuffer())).toEqual(new Uint8Array([0x89, 0x50, 0x4e, 0x47]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication', () => {
|
||||
it('should send HTTP Basic credentials for each identity', async () => {
|
||||
fetchMock.mockImplementation(async () => success({ id: 1 }));
|
||||
const bot = newClient();
|
||||
const user = newClient({ username: 'human@example.com', apiKey: 'another-key' });
|
||||
|
||||
await bot.POST('/messages', { body: message });
|
||||
await user.POST('/messages', { body: message });
|
||||
|
||||
expect(request(0).headers.get('authorization')).toBe(BASIC);
|
||||
expect(request(1).headers.get('authorization')).toBe(
|
||||
`Basic ${Buffer.from('human@example.com:another-key').toString('base64')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors', () => {
|
||||
it('should resolve with the data on success', async () => {
|
||||
fetchMock.mockResolvedValue(success({ id: 42 }));
|
||||
|
||||
const { data, response } = await newClient().POST('/messages', { body: message });
|
||||
|
||||
expect(data?.id).toBe(42);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should throw a typed error with code, msg and status on a non-2xx response', async () => {
|
||||
fetchMock.mockResolvedValue(failure(400, 'BAD_REQUEST', 'Invalid channel ID'));
|
||||
|
||||
const promise = newClient().POST('/messages', { body: message });
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(ZulipApiError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
name: 'ZulipApiError',
|
||||
status: 400,
|
||||
code: 'BAD_REQUEST',
|
||||
msg: 'Invalid channel ID',
|
||||
message: 'Zulip POST /api/v1/messages failed with 400 BAD_REQUEST: Invalid channel ID',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when a 2xx response carries result: "error"', async () => {
|
||||
fetchMock.mockResolvedValue(json({ result: 'error', code: 'BAD_REQUEST', msg: 'Nope' }));
|
||||
|
||||
await expect(newClient().POST('/messages', { body: message })).rejects.toMatchObject({
|
||||
status: 200,
|
||||
code: 'BAD_REQUEST',
|
||||
msg: 'Nope',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on a non-JSON failure, such as a proxy error page', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('<html>Bad Gateway</html>', { status: 502, statusText: 'Bad Gateway' }));
|
||||
|
||||
await expect(newClient().POST('/messages', { body: message })).rejects.toMatchObject({
|
||||
status: 502,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
msg: 'Bad Gateway',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not retry a 5xx', async () => {
|
||||
fetchMock.mockResolvedValue(failure(500, 'INTERNAL_ERROR', 'Internal server error'));
|
||||
|
||||
await expect(newClient().POST('/messages', { body: message })).rejects.toMatchObject({ status: 500 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject on a network error', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed'));
|
||||
|
||||
await expect(newClient().POST('/messages', { body: message })).rejects.toThrow('fetch failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limits', () => {
|
||||
it('should wait retry-after seconds from the body and retry after a 429', async () => {
|
||||
fetchMock.mockResolvedValueOnce(rateLimited(1.5)).mockResolvedValueOnce(success({ id: 7 }));
|
||||
|
||||
const { data } = await newClient().POST('/messages', { body: message });
|
||||
|
||||
expect(data?.id).toBe(7);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledOnce();
|
||||
expect(sleep).toHaveBeenCalledWith(1500);
|
||||
});
|
||||
|
||||
it('should fall back to the Retry-After header', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(rateLimited(undefined, { 'retry-after': '2' }))
|
||||
.mockResolvedValueOnce(success({ id: 7 }));
|
||||
|
||||
await newClient().POST('/messages', { body: message });
|
||||
|
||||
expect(sleep).toHaveBeenCalledWith(2000);
|
||||
});
|
||||
|
||||
it('should re-send the body on every attempt', async () => {
|
||||
const bodies: string[] = [];
|
||||
fetchMock.mockImplementation(async (request) => {
|
||||
bodies.push(await request.text());
|
||||
return bodies.length < 3 ? rateLimited(1) : success({ id: 7 });
|
||||
});
|
||||
|
||||
await newClient().POST('/messages', { body: message });
|
||||
|
||||
expect(bodies).toEqual([
|
||||
'type=channel&to=54&topic=release&content=hello+world',
|
||||
'type=channel&to=54&topic=release&content=hello+world',
|
||||
'type=channel&to=54&topic=release&content=hello+world',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should give up after the bounded number of attempts and throw the rate limit error', async () => {
|
||||
fetchMock.mockImplementation(async () => rateLimited(1));
|
||||
|
||||
await expect(newClient({ maxAttempts: 3 }).POST('/messages', { body: message })).rejects.toMatchObject({
|
||||
status: 429,
|
||||
code: 'RATE_LIMIT_HIT',
|
||||
msg: 'API usage exceeded rate limit',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(sleep).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not wait longer than the bounded maximum', async () => {
|
||||
fetchMock.mockImplementation(async () => rateLimited(120));
|
||||
|
||||
await expect(newClient({ maxRetryAfterMs: 30_000 }).POST('/messages', { body: message })).rejects.toMatchObject({
|
||||
status: 429,
|
||||
code: 'RATE_LIMIT_HIT',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout', () => {
|
||||
it('should abort a request that takes longer than the timeout', async () => {
|
||||
fetchMock.mockImplementation(
|
||||
(_, { signal }) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason))),
|
||||
);
|
||||
|
||||
const promise = newClient({ timeoutMs: 20 }).POST('/messages', { body: message });
|
||||
|
||||
await expect(promise).rejects.toMatchObject({ name: 'TimeoutError' });
|
||||
expect(signal().aborted).toBe(true);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hand fetch the signal as init rather than through the Request', async () => {
|
||||
fetchMock.mockImplementation(async () => success({ id: 1 }));
|
||||
|
||||
await newClient().POST('/messages', { body: message });
|
||||
|
||||
expect(signal()).toBeInstanceOf(AbortSignal);
|
||||
expect(request().signal).not.toBe(signal());
|
||||
});
|
||||
|
||||
it('should give every attempt a fresh, unaborted signal', async () => {
|
||||
fetchMock.mockResolvedValueOnce(rateLimited(1)).mockResolvedValueOnce(success({ id: 1 }));
|
||||
|
||||
await newClient().POST('/messages', { body: message });
|
||||
|
||||
expect(signal(0).aborted).toBe(false);
|
||||
expect(signal(1).aborted).toBe(false);
|
||||
expect(signal(0)).not.toBe(signal(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout against a real server', () => {
|
||||
// A mocked fetch cannot catch this regression: undici holds a signal routed through the `Request` constructor only
|
||||
// weakly, so the abort is lost once the GC runs. Each test keeps the GC busy while the request is in flight.
|
||||
let server: Server;
|
||||
let realm: string;
|
||||
|
||||
const listen = async (handler: RequestListener) => {
|
||||
server = createServer(handler);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
realm = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
};
|
||||
|
||||
const churnUntilSettled = async (promise: Promise<unknown>) => {
|
||||
let settled = false;
|
||||
promise.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true),
|
||||
);
|
||||
const junk: number[][] = [];
|
||||
while (!settled) {
|
||||
junk.push(new Array<number>(50_000).fill(Math.random()));
|
||||
if (junk.length > 40) {
|
||||
junk.length = 0;
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
|
||||
const post = () =>
|
||||
createZulipClient({ realm, username: USERNAME, apiKey: API_KEY, timeoutMs: 200, sleep }).POST('/messages', {
|
||||
body: message,
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('should abort when the server accepts the request and never answers', { timeout: 10_000 }, async () => {
|
||||
await listen(() => {});
|
||||
|
||||
const promise = post();
|
||||
await churnUntilSettled(promise);
|
||||
|
||||
await expect(promise).rejects.toMatchObject({ name: 'TimeoutError' });
|
||||
});
|
||||
|
||||
it('should abort when the server trickles the body forever', { timeout: 10_000 }, async () => {
|
||||
await listen((_, response) => {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.write('{"result":"success","msg":"","id":1');
|
||||
const trickle = setInterval(() => response.write(' '), 20);
|
||||
response.on('close', () => clearInterval(trickle));
|
||||
});
|
||||
|
||||
const promise = post();
|
||||
await churnUntilSettled(promise);
|
||||
|
||||
await expect(promise).rejects.toMatchObject({ name: 'TimeoutError' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging', () => {
|
||||
const logged = () =>
|
||||
[
|
||||
...vitest.mocked(Logger.prototype.log).mock.calls,
|
||||
...vitest.mocked(Logger.prototype.warn).mock.calls,
|
||||
...vitest.mocked(Logger.prototype.error).mock.calls,
|
||||
...vitest.mocked(Logger.prototype.debug).mock.calls,
|
||||
...vitest.mocked(Logger.prototype.verbose).mock.calls,
|
||||
].map((args) => args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
for (const method of ['log', 'error', 'debug', 'verbose'] as const) {
|
||||
vitest.spyOn(Logger.prototype, method).mockImplementation(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should log a retry without the Authorization header or the credentials', async () => {
|
||||
fetchMock.mockResolvedValueOnce(rateLimited(1)).mockResolvedValueOnce(success({ id: 7 }));
|
||||
|
||||
await newClient().POST('/messages', { body: message });
|
||||
|
||||
const lines = logged();
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain('POST /api/v1/messages');
|
||||
for (const line of lines) {
|
||||
expect(line).not.toContain('Authorization');
|
||||
expect(line).not.toContain('authorization');
|
||||
expect(line).not.toContain(BASIC);
|
||||
expect(line).not.toContain(API_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep the credentials out of the thrown error', async () => {
|
||||
fetchMock.mockResolvedValue(failure(401, 'UNAUTHORIZED', 'Invalid API key'));
|
||||
|
||||
const error = await newClient()
|
||||
.POST('/messages', { body: message })
|
||||
.then(() => undefined)
|
||||
.catch((error: unknown) => error as ZulipApiError);
|
||||
|
||||
const serialised = `${String(error)} ${error?.stack} ${JSON.stringify(error)}`;
|
||||
expect(error?.msg).toBe('Invalid API key');
|
||||
expect(serialised).not.toContain(BASIC);
|
||||
expect(serialised).not.toContain(API_KEY);
|
||||
expect(serialised).not.toContain('Authorization');
|
||||
expect(logged()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import createClient, { type Client, type Middleware } from 'openapi-fetch';
|
||||
import type { paths } from 'src/generated/zulip';
|
||||
|
||||
/**
|
||||
* A typed `openapi-fetch` client for Zulip's REST API, built from the generated 12.3 types.
|
||||
*
|
||||
* Transport rules (see the Zulip section of `claude.md`):
|
||||
* - every request body is sent `application/x-www-form-urlencoded` unless it is multipart;
|
||||
* - non-string fields, in bodies and in query strings, are JSON-encoded, never exploded;
|
||||
* - any non-2xx response or `result: "error"` body rejects with a `ZulipApiError`;
|
||||
* - a 429 is retried after the server's `retry-after`, within bounds; nothing else is retried;
|
||||
* - every request times out.
|
||||
*/
|
||||
export type ZulipClient = Client<paths>;
|
||||
|
||||
export type ZulipIdentity = { username: string; apiKey: string };
|
||||
|
||||
/**
|
||||
* The subset of `fetch` the client uses: `openapi-fetch` always hands it a fully built `Request`, and the client adds
|
||||
* the attempt's timeout as `init`. The signal must travel in `init`, never through the `Request` constructor: undici
|
||||
* only holds a signal routed that way through a `WeakRef`, so once the GC runs the abort no longer propagates and a
|
||||
* hung server blocks the caller for undici's 300s fallback, or forever if it trickles a body.
|
||||
*/
|
||||
export type ZulipFetch = (request: Request, init: { signal: AbortSignal }) => Promise<Response>;
|
||||
|
||||
export type ZulipClientOptions = ZulipIdentity & {
|
||||
/** `ZULIP_DOMAIN`: scheme and host, optionally followed by `/` or `/api`. */
|
||||
realm: string;
|
||||
/** Milliseconds before an attempt is aborted. */
|
||||
timeoutMs?: number;
|
||||
/** Attempts for a rate-limited request, counting the first one. */
|
||||
maxAttempts?: number;
|
||||
/** Longest single `retry-after` the client honours; a longer one fails the request instead of waiting. */
|
||||
maxRetryAfterMs?: number;
|
||||
/** The underlying fetch; defaults to the global one and is injected by tests. */
|
||||
fetch?: ZulipFetch;
|
||||
/** Pause between attempts; defaults to a timer and is injected by tests. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
};
|
||||
|
||||
type ZulipBody = { result?: string; msg?: string; code?: string; 'retry-after'?: number };
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
const DEFAULT_MAX_RETRY_AFTER_MS = 30_000;
|
||||
const DEFAULT_RETRY_AFTER_MS = 1_000;
|
||||
|
||||
export class ZulipApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
readonly msg: string,
|
||||
endpoint: string,
|
||||
) {
|
||||
super(`Zulip ${endpoint} failed with ${status} ${code}: ${msg}`);
|
||||
this.name = 'ZulipApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/** `${realm}/api/v1`, accepting a realm that ends in `/` or `/api` as the previous SDK did. */
|
||||
export const toApiUrl = (realm: string) => `${realm.replace(/\/+$/, '').replace(/\/api$/, '')}/api/v1`;
|
||||
|
||||
/**
|
||||
* Encodes fields the way the 12.3 spec declares every parameter we send: strings as they are, everything else
|
||||
* (`number`, `boolean`, array, object) as JSON, `undefined` omitted. Used for form bodies and query strings alike.
|
||||
*/
|
||||
export const encodeForm = (fields: Record<string, unknown>) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
if (value !== undefined) {
|
||||
params.append(name, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* The `body` and `bodySerializer` of a `multipart/form-data` request. Every part is a `File`, so it carries a filename
|
||||
* and a content type; `fetch` sets the `Content-Type` header with the boundary itself.
|
||||
*/
|
||||
export const multipart = <T extends Record<string, File>>(parts: T) => ({
|
||||
body: parts as unknown as { [K in keyof T]: string },
|
||||
bodySerializer: (body: unknown) => {
|
||||
const form = new FormData();
|
||||
for (const [name, file] of Object.entries(body as T)) {
|
||||
form.append(name, file);
|
||||
}
|
||||
return form;
|
||||
},
|
||||
});
|
||||
|
||||
const describe = (request: Request) => `${request.method} ${new URL(request.url).pathname}`;
|
||||
|
||||
const parseBody = async (response: Response) => {
|
||||
try {
|
||||
return (await response.clone().json()) as ZulipBody;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Body field first, `Retry-After` header second, both in seconds. */
|
||||
const readRetryAfterMs = async (response: Response) => {
|
||||
const body = await parseBody(response);
|
||||
const seconds =
|
||||
typeof body?.['retry-after'] === 'number' ? body['retry-after'] : Number(response.headers.get('retry-after'));
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : DEFAULT_RETRY_AFTER_MS;
|
||||
};
|
||||
|
||||
/** `openapi-fetch` sets `application/json` by default; every non-multipart body we send is form-urlencoded. */
|
||||
const formEncoding: Middleware = {
|
||||
onRequest({ request }) {
|
||||
if (request.body && !request.headers.get('content-type')?.startsWith('multipart/form-data')) {
|
||||
request.headers.set('content-type', 'application/x-www-form-urlencoded');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Zulip reports failures with a non-2xx status and a `result: "error"` body; either one rejects the call. */
|
||||
const errors: Middleware = {
|
||||
async onResponse({ request, response }) {
|
||||
const body = await parseBody(response);
|
||||
if (response.ok && body?.result !== 'error') {
|
||||
return;
|
||||
}
|
||||
throw new ZulipApiError(
|
||||
response.status,
|
||||
body?.code ?? 'UNKNOWN_ERROR',
|
||||
body?.msg ?? (response.statusText || `HTTP ${response.status}`),
|
||||
describe(request),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const defaultFetch: ZulipFetch = (request, init) => globalThis.fetch(request, init);
|
||||
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const createZulipClient = ({
|
||||
realm,
|
||||
username,
|
||||
apiKey,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
||||
maxRetryAfterMs = DEFAULT_MAX_RETRY_AFTER_MS,
|
||||
fetch = defaultFetch,
|
||||
sleep = defaultSleep,
|
||||
}: ZulipClientOptions): ZulipClient => {
|
||||
const logger = new Logger('ZulipClient');
|
||||
|
||||
/**
|
||||
* Retries 429 only: a rate-limited request was not processed, whereas retrying a 5xx on `POST /messages` could
|
||||
* double-post. `Request` bodies are single-use streams, so each attempt sends a clone of the original.
|
||||
*/
|
||||
const fetchWithRetry = async (request: Request) => {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const response = await fetch(request.clone(), { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (response.status !== 429) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const retryAfterMs = await readRetryAfterMs(response);
|
||||
if (attempt >= maxAttempts || retryAfterMs > maxRetryAfterMs) {
|
||||
return response;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Rate limited on ${describe(request)}, retrying in ${retryAfterMs}ms (attempt ${attempt}/${maxAttempts})`,
|
||||
);
|
||||
await sleep(retryAfterMs);
|
||||
}
|
||||
};
|
||||
|
||||
const client = createClient<paths>({
|
||||
baseUrl: toApiUrl(realm),
|
||||
headers: { Authorization: `Basic ${Buffer.from(`${username}:${apiKey}`).toString('base64')}` },
|
||||
querySerializer: (query) => encodeForm(query),
|
||||
bodySerializer: (body: unknown) => encodeForm(body as Record<string, unknown>),
|
||||
fetch: fetchWithRetry,
|
||||
});
|
||||
client.use(formEncoding, errors);
|
||||
return client;
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import { ZulipConfig } from 'src/interfaces/zulip.interface';
|
||||
import { ZulipApiError } from 'src/repositories/zulip.client';
|
||||
import { ZulipRepository } from 'src/repositories/zulip.repository';
|
||||
import { Mock, afterEach, beforeEach, describe, expect, it, vitest } from 'vitest';
|
||||
|
||||
const config: ZulipConfig = {
|
||||
realm: 'https://zulip.example.com',
|
||||
bot: { username: 'bot@example.com', apiKey: 'bot-key' },
|
||||
user: { username: 'human@example.com', apiKey: 'user-key' },
|
||||
};
|
||||
|
||||
const basic = ({ username, apiKey }: { username: string; apiKey: string }) =>
|
||||
`Basic ${Buffer.from(`${username}:${apiKey}`).toString('base64')}`;
|
||||
|
||||
const json = (body: unknown, init: ResponseInit = {}) =>
|
||||
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init });
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const image = (type = 'image/png') => new Response(PNG, { status: 200, headers: { 'content-type': type } });
|
||||
|
||||
describe('ZulipRepository', () => {
|
||||
let sut: ZulipRepository;
|
||||
let fetchMock: Mock<(input: string | URL | Request, init?: RequestInit) => Promise<Response>>;
|
||||
|
||||
/** The i-th call, as a `Request` regardless of how it was made. */
|
||||
const request = (index: number) => {
|
||||
const [input, init] = fetchMock.mock.calls[index];
|
||||
return input instanceof Request ? input : new Request(input, init);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vitest.fn();
|
||||
vitest.stubGlobal('fetch', fetchMock);
|
||||
sut = new ZulipRepository();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vitest.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('before init', () => {
|
||||
it('should throw a clear error from sendMessage', async () => {
|
||||
await expect(sut.sendMessage({ stream: 54, topic: 'release', content: 'hi' })).rejects.toThrow(
|
||||
'Zulip client not initialised',
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw a clear error from createEmote', async () => {
|
||||
await expect(sut.createEmote('catJAM', 'https://example.com/catJAM.png')).rejects.toThrow(
|
||||
'Zulip client not initialised',
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage', () => {
|
||||
beforeEach(async () => {
|
||||
await sut.init(config);
|
||||
});
|
||||
|
||||
it('should post a channel message as the bot and return the message ID', async () => {
|
||||
fetchMock.mockResolvedValue(json({ result: 'success', msg: '', id: 42 }));
|
||||
|
||||
await expect(sut.sendMessage({ stream: 54, topic: 'release', content: 'v1.2.0 is out' })).resolves.toEqual({
|
||||
id: 42,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(request(0).method).toBe('POST');
|
||||
expect(request(0).url).toBe('https://zulip.example.com/api/v1/messages');
|
||||
expect(request(0).headers.get('authorization')).toBe(basic(config.bot));
|
||||
expect(request(0).headers.get('content-type')).toBe('application/x-www-form-urlencoded');
|
||||
expect(await request(0).text()).toBe('type=channel&to=54&topic=release&content=v1.2.0+is+out');
|
||||
});
|
||||
|
||||
it('should omit the topic when there is none', async () => {
|
||||
fetchMock.mockResolvedValue(json({ result: 'success', msg: '', id: 1 }));
|
||||
|
||||
await sut.sendMessage({ stream: 'general', content: 'hi' });
|
||||
|
||||
expect(await request(0).text()).toBe('type=channel&to=general&content=hi');
|
||||
});
|
||||
|
||||
it('should throw on a Zulip error instead of resolving', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json(
|
||||
{ result: 'error', code: 'STREAM_DOES_NOT_EXIST', msg: "Channel with ID '54' does not exist" },
|
||||
{ status: 400 },
|
||||
),
|
||||
);
|
||||
|
||||
const promise = sut.sendMessage({ stream: 54, topic: 'release', content: 'hi' });
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(ZulipApiError);
|
||||
await expect(promise).rejects.toMatchObject({ status: 400, code: 'STREAM_DOES_NOT_EXIST' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEmote', () => {
|
||||
beforeEach(async () => {
|
||||
await sut.init(config);
|
||||
});
|
||||
|
||||
it('should fetch the image and upload it as the user, lowercased, with a filename and content type', async () => {
|
||||
fetchMock.mockResolvedValueOnce(image('image/png')).mockResolvedValueOnce(json({ result: 'success', msg: '' }));
|
||||
|
||||
await sut.createEmote('catJAM', 'https://example.com/catJAM.png');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(request(0).url).toBe('https://example.com/catJAM.png');
|
||||
expect(request(0).headers.get('authorization')).toBeNull();
|
||||
|
||||
const upload = request(1);
|
||||
expect(upload.method).toBe('POST');
|
||||
expect(upload.url).toBe('https://zulip.example.com/api/v1/realm/emoji/catjam');
|
||||
expect(upload.headers.get('authorization')).toBe(basic(config.user));
|
||||
expect(upload.headers.get('content-type')).toMatch(/^multipart\/form-data; boundary=/);
|
||||
|
||||
const part = (await upload.formData()).get('filename') as File;
|
||||
expect(part).toBeInstanceOf(File);
|
||||
expect(part.name).toBe('catjam.png');
|
||||
expect(part.type).toBe('image/png');
|
||||
expect(new Uint8Array(await part.arrayBuffer())).toEqual(PNG);
|
||||
});
|
||||
|
||||
it('should name the file after the content type, ignoring the URL', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(image('image/gif; charset=binary'))
|
||||
.mockResolvedValueOnce(json({ result: 'success', msg: '' }));
|
||||
|
||||
await sut.createEmote('pepeD', 'https://cdn.betterttv.net/emote/5f1b0186cf6d2144653d2970/3x');
|
||||
|
||||
const part = (await request(1).formData()).get('filename') as File;
|
||||
expect(part.name).toBe('peped.gif');
|
||||
expect(part.type).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('should fall back to the URL extension for an unknown content type', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(PNG, { status: 200 }))
|
||||
.mockResolvedValueOnce(json({ result: 'success', msg: '' }));
|
||||
|
||||
await sut.createEmote('catJAM', 'https://example.com/catJAM.webp');
|
||||
|
||||
const part = (await request(1).formData()).get('filename') as File;
|
||||
expect(part.name).toBe('catjam.webp');
|
||||
});
|
||||
|
||||
it('should throw when the image cannot be fetched and not upload anything', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('not found', { status: 404 }));
|
||||
|
||||
await expect(sut.createEmote('catJAM', 'https://example.com/catJAM.png')).rejects.toThrow(
|
||||
'Could not fetch emote image https://example.com/catJAM.png: 404',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should throw when the upload fails', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(image())
|
||||
.mockResolvedValueOnce(
|
||||
json(
|
||||
{ result: 'error', code: 'BAD_REQUEST', msg: 'This endpoint does not accept bot requests' },
|
||||
{ status: 400 },
|
||||
),
|
||||
);
|
||||
|
||||
const promise = sut.createEmote('catJAM', 'https://example.com/catJAM.png');
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(ZulipApiError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
status: 400,
|
||||
code: 'BAD_REQUEST',
|
||||
msg: 'This endpoint does not accept bot requests',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,63 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { IZulipInterface, MessagePayload, type ZulipConfig } from 'src/interfaces/zulip.interface';
|
||||
// @ts-expect-error: that stupid sdk does not have types
|
||||
import zulip from 'zulip-js';
|
||||
import { createZulipClient, multipart, type ZulipClient } from 'src/repositories/zulip.client';
|
||||
|
||||
type Zulip = {
|
||||
messages: {
|
||||
send: ({
|
||||
to,
|
||||
type,
|
||||
topic,
|
||||
content,
|
||||
}: {
|
||||
to: string | number;
|
||||
type: 'stream' | 'channel' | 'direct';
|
||||
topic?: string;
|
||||
content: string;
|
||||
}) => Promise<string>;
|
||||
};
|
||||
callEndpoint: (path: string, method: 'GET' | 'POST', params: object) => Promise<void>;
|
||||
config: {
|
||||
realm: string;
|
||||
apiURL: string;
|
||||
username: string;
|
||||
apiKey: string;
|
||||
};
|
||||
const IMAGE_TIMEOUT_MS = 30_000;
|
||||
const IMAGE_EXTENSIONS: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/jpeg': 'jpg',
|
||||
};
|
||||
|
||||
export class ZulipRepository implements IZulipInterface {
|
||||
private logger = new Logger(ZulipRepository.name);
|
||||
private zulip: Zulip = {} as Zulip;
|
||||
private zulipUser: Zulip = {} as Zulip;
|
||||
private clients?: { bot: ZulipClient; user: ZulipClient };
|
||||
|
||||
async init({ realm, bot, user }: ZulipConfig) {
|
||||
this.zulip = await zulip({ realm, ...bot });
|
||||
this.zulipUser = await zulip({ realm, ...user });
|
||||
this.clients = {
|
||||
bot: createZulipClient({ realm, ...bot }),
|
||||
user: createZulipClient({ realm, ...user }),
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessage({ stream, content, topic }: MessagePayload) {
|
||||
const response = await this.zulip.messages.send({ to: stream, type: 'stream', topic, content });
|
||||
this.logger.debug(response);
|
||||
/** The bot account posts messages. */
|
||||
private get bot() {
|
||||
return this.client('bot');
|
||||
}
|
||||
|
||||
/** The human account uploads emoji: Zulip answers `This endpoint does not accept bot requests` otherwise. */
|
||||
private get user() {
|
||||
return this.client('user');
|
||||
}
|
||||
|
||||
private client(identity: 'bot' | 'user') {
|
||||
if (!this.clients) {
|
||||
throw new Error('Zulip client not initialised: call init() first');
|
||||
}
|
||||
return this.clients[identity];
|
||||
}
|
||||
|
||||
async sendMessage({ stream, topic, content }: MessagePayload) {
|
||||
const { data } = await this.bot.POST('/messages', { body: { type: 'channel', to: stream, topic, content } });
|
||||
return { id: data!.id };
|
||||
}
|
||||
|
||||
async createEmote(name: string, emoteUrl: string) {
|
||||
const authentication = `Basic ${Buffer.from(`${this.zulipUser.config.username}:${this.zulipUser.config.apiKey}`).toString('base64')}`;
|
||||
const emote = await fetch(emoteUrl).then((response) => response.blob());
|
||||
const user = this.user;
|
||||
const emojiName = name.toLowerCase();
|
||||
|
||||
const form = new FormData();
|
||||
form.append('filename', emote);
|
||||
const image = await fetch(emoteUrl, { signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS) });
|
||||
if (!image.ok) {
|
||||
throw new Error(`Could not fetch emote image ${emoteUrl}: ${image.status}`);
|
||||
}
|
||||
|
||||
await fetch(`${this.zulipUser.config.apiURL}/realm/emoji/${name.toLowerCase()}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authentication },
|
||||
body: form,
|
||||
// Zulip needs a real filename with an extension and the image's content type on the multipart part.
|
||||
const contentType = image.headers.get('content-type')?.split(';')[0].trim() || 'application/octet-stream';
|
||||
const extension = IMAGE_EXTENSIONS[contentType] ?? new URL(emoteUrl).pathname.match(/\.(\w+)$/)?.[1] ?? 'png';
|
||||
const file = new File([await image.arrayBuffer()], `${emojiName}.${extension}`, { type: contentType });
|
||||
|
||||
await user.POST('/realm/emoji/{emoji_name}', {
|
||||
params: { path: { emoji_name: emojiName } },
|
||||
...multipart({ filename: file }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { CommandInteraction, GuildEmoji } from 'discord.js';
|
||||
import { IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { IDiscordInterface } from 'src/interfaces/discord.interface';
|
||||
import { IFourthwallRepository } from 'src/interfaces/fourthwall.interface';
|
||||
@@ -7,7 +9,18 @@ import { IMattermostInterface } from 'src/interfaces/mattermost.interface';
|
||||
import { IOutlineInterface } from 'src/interfaces/outline.interface';
|
||||
import { IZulipInterface } from 'src/interfaces/zulip.interface';
|
||||
import { ChatService } from 'src/services/chat.service';
|
||||
import { Mocked, describe, expect, it, vitest } from 'vitest';
|
||||
import { Mocked, afterEach, beforeEach, describe, expect, it, vitest } from 'vitest';
|
||||
|
||||
vitest.mock('src/config', () => ({
|
||||
getConfig: () => ({
|
||||
bot: { token: 'dev' },
|
||||
zulip: {
|
||||
bot: { username: 'bot@example.com', apiKey: 'bot-key' },
|
||||
user: { username: 'human@example.com', apiKey: 'user-key' },
|
||||
realm: 'https://zulip.example.com',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const newGithubMockRepository = (): Mocked<IGithubInterface> => ({
|
||||
search: vitest.fn(),
|
||||
@@ -124,6 +137,7 @@ describe('Bot test', () => {
|
||||
let databaseMock: Mocked<IDatabaseRepository>;
|
||||
let mattermostMock: Mocked<IMattermostInterface>;
|
||||
let zulipMock: Mocked<IZulipInterface>;
|
||||
let fetchMock: ReturnType<typeof vitest.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
discordMock = newDiscordMockRepository();
|
||||
@@ -134,6 +148,9 @@ describe('Bot test', () => {
|
||||
databaseMock = newDatabaseMockRepository();
|
||||
mattermostMock = newMattermostMockRepository();
|
||||
zulipMock = newZulipMockRepository();
|
||||
// 7TV and BTTV lookups go through the global fetch.
|
||||
fetchMock = vitest.fn();
|
||||
vitest.stubGlobal('fetch', fetchMock);
|
||||
|
||||
sut = new ChatService(
|
||||
databaseMock,
|
||||
@@ -147,6 +164,10 @@ describe('Bot test', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vitest.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
@@ -443,4 +464,349 @@ describe('Bot test', () => {
|
||||
expect(result[1]).toContain('function test() { return "immich-app/immich @ anotherref: file.txt"; }');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEmote', () => {
|
||||
it('should upload the emote to Zulip and then create it on Discord', async () => {
|
||||
const created = { id: '1', name: 'catJAM' } as unknown as GuildEmoji;
|
||||
discordMock.createEmote.mockResolvedValue(created);
|
||||
|
||||
await expect(sut.createEmote('catJAM', 'https://example.com/catJAM.png', 'guild-1')).resolves.toBe(created);
|
||||
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledWith('catJAM', 'https://example.com/catJAM.png');
|
||||
expect(discordMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(discordMock.createEmote).toHaveBeenCalledWith('catJAM', 'https://example.com/catJAM.png', 'guild-1');
|
||||
expect(mattermostMock.createEmote).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const [zulip] = zulipMock.createEmote.mock.invocationCallOrder;
|
||||
const [discord] = discordMock.createEmote.mock.invocationCallOrder;
|
||||
expect(zulip).toBeLessThan(discord);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create7TvEmote', () => {
|
||||
const id = '01F6MZGCNG000255K8Q0CMP4Q0';
|
||||
const sevenTvEmote = (files: { name: string; format: string; size: number }[]) =>
|
||||
new Response(JSON.stringify({ id, name: 'catJAM', host: { url: `//cdn.7tv.app/emote/${id}`, files } }));
|
||||
|
||||
it('should fetch the emote from 7TV and pick the last GIF under 256000 bytes', async () => {
|
||||
const created = { id: '1', name: 'catJAM' } as unknown as GuildEmoji;
|
||||
discordMock.createEmote.mockResolvedValue(created);
|
||||
fetchMock.mockResolvedValue(
|
||||
sevenTvEmote([
|
||||
{ name: '1x.webp', format: 'WEBP', size: 10_000 },
|
||||
{ name: '1x.gif', format: 'GIF', size: 50_000 },
|
||||
{ name: '2x.gif', format: 'GIF', size: 150_000 },
|
||||
{ name: '2x.webp', format: 'WEBP', size: 40_000 },
|
||||
{ name: '3x.gif', format: 'GIF', size: 255_999 },
|
||||
{ name: '4x.gif', format: 'GIF', size: 256_000 },
|
||||
{ name: '4x.webp', format: 'WEBP', size: 200_000 },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(sut.create7TvEmote(id, 'guild-1', null)).resolves.toBe(created);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(`https://7tv.io/v3/emotes/${id}`);
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledWith('catJAM', `https://cdn.7tv.app/emote/${id}/3x.gif`);
|
||||
expect(discordMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(discordMock.createEmote).toHaveBeenCalledWith(
|
||||
'catJAM',
|
||||
`https://cdn.7tv.app/emote/${id}/3x.gif`,
|
||||
'guild-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to the last WEBP under 256000 bytes when no GIF fits', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sevenTvEmote([
|
||||
{ name: '1x.webp', format: 'WEBP', size: 10_000 },
|
||||
{ name: '2x.webp', format: 'WEBP', size: 100_000 },
|
||||
{ name: '3x.webp', format: 'WEBP', size: 256_000 },
|
||||
{ name: '1x.gif', format: 'GIF', size: 300_000 },
|
||||
{ name: '4x.webp', format: 'WEBP', size: 500_000 },
|
||||
]),
|
||||
);
|
||||
|
||||
await sut.create7TvEmote(id, 'guild-1', null);
|
||||
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledWith('catJAM', `https://cdn.7tv.app/emote/${id}/2x.webp`);
|
||||
expect(discordMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(discordMock.createEmote).toHaveBeenCalledWith(
|
||||
'catJAM',
|
||||
`https://cdn.7tv.app/emote/${id}/2x.webp`,
|
||||
'guild-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the given name over the 7TV name', async () => {
|
||||
fetchMock.mockResolvedValue(sevenTvEmote([{ name: '1x.gif', format: 'GIF', size: 10_000 }]));
|
||||
|
||||
await sut.create7TvEmote(id, 'guild-1', 'dancing_cat');
|
||||
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledWith('dancing_cat', `https://cdn.7tv.app/emote/${id}/1x.gif`);
|
||||
expect(discordMock.createEmote).toHaveBeenCalledWith(
|
||||
'dancing_cat',
|
||||
`https://cdn.7tv.app/emote/${id}/1x.gif`,
|
||||
'guild-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBttvEmote', () => {
|
||||
const id = '5f1b0186cf6d2144653d2970';
|
||||
const bttvEmote = () => new Response(JSON.stringify({ id, code: 'catJAM', imageType: 'gif', animated: 'true' }));
|
||||
|
||||
it('should fetch the emote from BTTV and use the 3x CDN image', async () => {
|
||||
const created = { id: '1', name: 'catJAM' } as unknown as GuildEmoji;
|
||||
discordMock.createEmote.mockResolvedValue(created);
|
||||
fetchMock.mockResolvedValue(bttvEmote());
|
||||
|
||||
await expect(sut.createBttvEmote(id, 'guild-1', null)).resolves.toBe(created);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(`https://api.betterttv.net/3/emotes/${id}`);
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledWith('catJAM', `https://cdn.betterttv.net/emote/${id}/3x`);
|
||||
expect(discordMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(discordMock.createEmote).toHaveBeenCalledWith(
|
||||
'catJAM',
|
||||
`https://cdn.betterttv.net/emote/${id}/3x`,
|
||||
'guild-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the given name over the BTTV code', async () => {
|
||||
fetchMock.mockResolvedValue(bttvEmote());
|
||||
|
||||
await sut.createBttvEmote(id, 'guild-1', 'dancing_cat');
|
||||
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledWith('dancing_cat', `https://cdn.betterttv.net/emote/${id}/3x`);
|
||||
expect(discordMock.createEmote).toHaveBeenCalledWith(
|
||||
'dancing_cat',
|
||||
`https://cdn.betterttv.net/emote/${id}/3x`,
|
||||
'guild-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEmoteFromExistingOne', () => {
|
||||
const url = 'https://cdn.discordapp.com/emojis/123456789012345678.png';
|
||||
|
||||
it('should parse the emote mention and use the Discord CDN image', async () => {
|
||||
const created = { id: '1', name: 'catJAM' } as unknown as GuildEmoji;
|
||||
discordMock.createEmote.mockResolvedValue(created);
|
||||
|
||||
await expect(sut.createEmoteFromExistingOne('<:catJAM:123456789012345678>', 'guild-1', null)).resolves.toBe(
|
||||
created,
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledWith('catJAM', url);
|
||||
expect(discordMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(discordMock.createEmote).toHaveBeenCalledWith('catJAM', url, 'guild-1');
|
||||
});
|
||||
|
||||
it('should use the given name over the mentioned one', async () => {
|
||||
await sut.createEmoteFromExistingOne('<:catJAM:123456789012345678>', 'guild-1', 'dancing_cat');
|
||||
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledWith('dancing_cat', url);
|
||||
expect(discordMock.createEmote).toHaveBeenCalledWith('dancing_cat', url, 'guild-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncEmotes', () => {
|
||||
const newInteraction = () => {
|
||||
const reply = { edit: vitest.fn() };
|
||||
const deferReply = vitest.fn().mockResolvedValue(reply);
|
||||
const interaction = { guildId: 'guild-1', deferReply } as unknown as CommandInteraction;
|
||||
return { interaction, deferReply, reply };
|
||||
};
|
||||
|
||||
it('should upload every Discord emote to Zulip and Mattermost, then report done', async () => {
|
||||
const { interaction, deferReply, reply } = newInteraction();
|
||||
discordMock.getEmotes.mockResolvedValue([
|
||||
{ identifier: 'catJAM:1', name: 'catJAM', url: 'https://cdn.discordapp.com/emojis/1.webp', animated: false },
|
||||
{ identifier: 'a:pepeD:2', name: 'pepeD', url: 'https://cdn.discordapp.com/emojis/2.webp', animated: true },
|
||||
{ identifier: 'nameless:3', name: null, url: 'https://cdn.discordapp.com/emojis/3.png', animated: false },
|
||||
]);
|
||||
|
||||
await sut.syncEmotes(interaction);
|
||||
|
||||
expect(discordMock.getEmotes).toHaveBeenCalledOnce();
|
||||
expect(discordMock.getEmotes).toHaveBeenCalledWith('guild-1');
|
||||
|
||||
const uploads = [
|
||||
['catJAM', 'https://cdn.discordapp.com/emojis/1.webp'],
|
||||
['pepeD', 'https://cdn.discordapp.com/emojis/2.gif'],
|
||||
['nameless:3', 'https://cdn.discordapp.com/emojis/3.png'],
|
||||
];
|
||||
expect(zulipMock.createEmote.mock.calls).toEqual(uploads);
|
||||
expect(mattermostMock.createEmote.mock.calls).toEqual(uploads);
|
||||
expect(discordMock.createEmote).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
expect(deferReply).toHaveBeenCalledOnce();
|
||||
expect(reply.edit).toHaveBeenCalledOnce();
|
||||
expect(reply.edit).toHaveBeenCalledWith('Done syncing');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
animated: true,
|
||||
url: 'https://cdn.discordapp.com/emojis/1.webp',
|
||||
expected: 'https://cdn.discordapp.com/emojis/1.gif',
|
||||
},
|
||||
{
|
||||
animated: true,
|
||||
url: 'https://cdn.discordapp.com/emojis/1.png',
|
||||
expected: 'https://cdn.discordapp.com/emojis/1.gif',
|
||||
},
|
||||
{
|
||||
animated: true,
|
||||
url: 'https://cdn.discordapp.com/emojis/1.gif',
|
||||
expected: 'https://cdn.discordapp.com/emojis/1.gif',
|
||||
},
|
||||
{
|
||||
animated: false,
|
||||
url: 'https://cdn.discordapp.com/emojis/1.webp',
|
||||
expected: 'https://cdn.discordapp.com/emojis/1.webp',
|
||||
},
|
||||
])('should upload $url as $expected when animated is $animated', async ({ animated, url, expected }) => {
|
||||
const { interaction } = newInteraction();
|
||||
discordMock.getEmotes.mockResolvedValue([{ identifier: 'catJAM:1', name: 'catJAM', url, animated }]);
|
||||
|
||||
await sut.syncEmotes(interaction);
|
||||
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledWith('catJAM', expected);
|
||||
expect(mattermostMock.createEmote).toHaveBeenCalledOnce();
|
||||
expect(mattermostMock.createEmote).toHaveBeenCalledWith('catJAM', expected);
|
||||
});
|
||||
|
||||
it('should defer the reply, upload each emote to Zulip then Mattermost one at a time, then edit the reply', async () => {
|
||||
const { interaction, deferReply, reply } = newInteraction();
|
||||
discordMock.getEmotes.mockResolvedValue([
|
||||
{ identifier: 'catJAM:1', name: 'catJAM', url: 'https://cdn.discordapp.com/emojis/1.webp', animated: false },
|
||||
{ identifier: 'pepeD:2', name: 'pepeD', url: 'https://cdn.discordapp.com/emojis/2.webp', animated: false },
|
||||
]);
|
||||
|
||||
await sut.syncEmotes(interaction);
|
||||
|
||||
const [defer] = deferReply.mock.invocationCallOrder;
|
||||
const [getEmotes] = discordMock.getEmotes.mock.invocationCallOrder;
|
||||
const [zulipFirst, zulipSecond] = zulipMock.createEmote.mock.invocationCallOrder;
|
||||
const [mattermostFirst, mattermostSecond] = mattermostMock.createEmote.mock.invocationCallOrder;
|
||||
const [edit] = reply.edit.mock.invocationCallOrder;
|
||||
expect(defer).toBeLessThan(getEmotes);
|
||||
expect(getEmotes).toBeLessThan(zulipFirst);
|
||||
expect(zulipFirst).toBeLessThan(mattermostFirst);
|
||||
expect(mattermostFirst).toBeLessThan(zulipSecond);
|
||||
expect(zulipSecond).toBeLessThan(mattermostSecond);
|
||||
expect(mattermostSecond).toBeLessThan(edit);
|
||||
});
|
||||
|
||||
describe('failures', () => {
|
||||
const emotes = [
|
||||
{ identifier: 'catJAM:1', name: 'catJAM', url: 'https://cdn.discordapp.com/emojis/1.webp', animated: false },
|
||||
{ identifier: 'pepeD:2', name: 'pepeD', url: 'https://cdn.discordapp.com/emojis/2.webp', animated: false },
|
||||
{ identifier: 'nameless:3', name: null, url: 'https://cdn.discordapp.com/emojis/3.png', animated: false },
|
||||
];
|
||||
const uploads = [
|
||||
['catJAM', 'https://cdn.discordapp.com/emojis/1.webp'],
|
||||
['pepeD', 'https://cdn.discordapp.com/emojis/2.webp'],
|
||||
['nameless:3', 'https://cdn.discordapp.com/emojis/3.png'],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vitest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
||||
discordMock.getEmotes.mockResolvedValue(emotes);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vitest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should keep syncing when a Zulip upload fails and report the emote', async () => {
|
||||
const { interaction, reply } = newInteraction();
|
||||
zulipMock.createEmote.mockRejectedValueOnce(new Error('This endpoint does not accept bot requests'));
|
||||
|
||||
await sut.syncEmotes(interaction);
|
||||
|
||||
expect(zulipMock.createEmote.mock.calls).toEqual(uploads);
|
||||
expect(mattermostMock.createEmote.mock.calls).toEqual(uploads);
|
||||
expect(Logger.prototype.error).toHaveBeenCalledOnce();
|
||||
expect(Logger.prototype.error).toHaveBeenCalledWith(
|
||||
'Could not sync emote catJAM - https://cdn.discordapp.com/emojis/1.webp to Zulip',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(reply.edit).toHaveBeenCalledOnce();
|
||||
expect(reply.edit).toHaveBeenCalledWith('Done syncing, 1 failed: catJAM');
|
||||
});
|
||||
|
||||
it('should keep syncing when a Mattermost upload fails and report the emote', async () => {
|
||||
const { interaction, reply } = newInteraction();
|
||||
mattermostMock.createEmote.mockResolvedValueOnce().mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await sut.syncEmotes(interaction);
|
||||
|
||||
expect(zulipMock.createEmote.mock.calls).toEqual(uploads);
|
||||
expect(mattermostMock.createEmote.mock.calls).toEqual(uploads);
|
||||
expect(Logger.prototype.error).toHaveBeenCalledWith(
|
||||
'Could not sync emote pepeD - https://cdn.discordapp.com/emojis/2.webp to Mattermost',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(reply.edit).toHaveBeenCalledWith('Done syncing, 1 failed: pepeD');
|
||||
});
|
||||
|
||||
it('should report each failed emote once, whichever platforms failed', async () => {
|
||||
const { interaction, reply } = newInteraction();
|
||||
zulipMock.createEmote.mockRejectedValueOnce(new Error('zulip')).mockRejectedValueOnce(new Error('zulip'));
|
||||
mattermostMock.createEmote.mockRejectedValueOnce(new Error('mattermost'));
|
||||
|
||||
await sut.syncEmotes(interaction);
|
||||
|
||||
expect(zulipMock.createEmote.mock.calls).toEqual(uploads);
|
||||
expect(mattermostMock.createEmote.mock.calls).toEqual(uploads);
|
||||
expect(Logger.prototype.error).toHaveBeenCalledTimes(3);
|
||||
expect(reply.edit).toHaveBeenCalledWith('Done syncing, 2 failed: catJAM, pepeD');
|
||||
});
|
||||
|
||||
it("should keep the report within Discord's message limit when every emote fails", async () => {
|
||||
const { interaction, reply } = newInteraction();
|
||||
discordMock.getEmotes.mockResolvedValue(
|
||||
Array.from({ length: 300 }, (_, index) => ({
|
||||
identifier: `emote_number_${index}:${index}`,
|
||||
name: `emote_number_${index}`,
|
||||
url: `https://cdn.discordapp.com/emojis/${index}.webp`,
|
||||
animated: false,
|
||||
})),
|
||||
);
|
||||
zulipMock.createEmote.mockRejectedValue(new Error('This endpoint does not accept bot requests'));
|
||||
|
||||
await sut.syncEmotes(interaction);
|
||||
|
||||
expect(zulipMock.createEmote).toHaveBeenCalledTimes(300);
|
||||
expect(mattermostMock.createEmote).toHaveBeenCalledTimes(300);
|
||||
expect(reply.edit).toHaveBeenCalledOnce();
|
||||
const [report] = reply.edit.mock.calls[0] as [string];
|
||||
expect(report).toMatch(/^Done syncing, 300 failed: emote_number_0, emote_number_1, /);
|
||||
expect(report).toMatch(/\.\.\.$/);
|
||||
expect(report).toHaveLength(2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should leave Zulip initialisation to ZulipService', async () => {
|
||||
await sut.init();
|
||||
|
||||
expect(zulipMock.init).not.toHaveBeenCalled();
|
||||
expect(mattermostMock.init).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,15 +97,12 @@ export class ChatService {
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
const { bot, zulip } = getConfig();
|
||||
const { bot } = getConfig();
|
||||
if (bot.token !== 'dev') {
|
||||
await this.discord.login(bot.token);
|
||||
}
|
||||
|
||||
if (zulip.bot.apiKey !== 'dev' && zulip.user.apiKey !== 'dev') {
|
||||
await this.zulip.init(zulip);
|
||||
}
|
||||
|
||||
// The Zulip clients are initialised once, by ZulipService.
|
||||
await this.mattermost.init();
|
||||
this.mattermost.registerEventListener(WebSocketEvents.Posted, (msg) => this.onMattermostPosted(msg));
|
||||
this.mattermost.registerEventListener(WebSocketEvents.PostEdited, (msg) => this.onMattermostEdited(msg));
|
||||
@@ -547,8 +544,8 @@ ${formattedCode}
|
||||
|
||||
try {
|
||||
await this.zulip.createEmote(name, emote);
|
||||
} catch {
|
||||
this.logger.error(`Could not create emote ${name} - ${emote} on Zulip`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Could not create emote ${name} - ${emote} on Zulip`, error);
|
||||
}
|
||||
return this.discord.createEmote(name, emote, guildId);
|
||||
}
|
||||
@@ -683,13 +680,36 @@ ${formattedCode}
|
||||
|
||||
const deferredInteraction = await interaction.deferReply();
|
||||
|
||||
const failed: string[] = [];
|
||||
for (const emote of await this.discord.getEmotes(interaction.guildId)) {
|
||||
const name = emote.name ?? emote.identifier;
|
||||
const url = emote.animated ? emote.url.replace(/\.(?<extension>[a-zA-Z]+?)$/, '.gif') : emote.url;
|
||||
await this.zulip.createEmote(emote.name ?? emote.identifier, url);
|
||||
await this.mattermost.createEmote(emote.name ?? emote.identifier, url);
|
||||
// One bad emote, or one platform being down, must not abort the rest of the sync.
|
||||
const zulipSynced = await this.syncEmote('Zulip', name, url, () => this.zulip.createEmote(name, url));
|
||||
const mattermostSynced = await this.syncEmote('Mattermost', name, url, () =>
|
||||
this.mattermost.createEmote(name, url),
|
||||
);
|
||||
if (!zulipSynced || !mattermostSynced) {
|
||||
failed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
await deferredInteraction.edit('Done syncing');
|
||||
// A systemic failure (a bot account cannot upload emoji) lists every emote; Discord caps a message at 2000.
|
||||
await deferredInteraction.edit(
|
||||
failed.length === 0
|
||||
? 'Done syncing'
|
||||
: shorten(`Done syncing, ${failed.length} failed: ${failed.join(', ')}`, 2000),
|
||||
);
|
||||
}
|
||||
|
||||
private async syncEmote(platform: string, name: string, url: string, upload: () => Promise<void>) {
|
||||
try {
|
||||
await upload();
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Could not sync emote ${name} - ${url} to ${platform}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async pruneMessagesInChannel(channel: SendableChannels, userId: string, deleteAfter: DateTime) {
|
||||
|
||||
@@ -2869,6 +2869,20 @@ describe(WebhookService.name, () => {
|
||||
);
|
||||
expect(sent()).toEqual({ discord: [], mattermost: [], zulip: [] });
|
||||
});
|
||||
|
||||
it('should fail the webhook when the Zulip announcement fails, after the other posts were made', async () => {
|
||||
zulipMock.sendMessage.mockRejectedValue(
|
||||
new Error('Zulip POST /api/v1/messages failed with 429 RATE_LIMIT_HIT: API usage exceeded rate limit'),
|
||||
);
|
||||
|
||||
await expect(sut.onGithub(releaseEvent(makeRelease()), 'github-slug')).rejects.toThrow('RATE_LIMIT_HIT');
|
||||
|
||||
await vitest.waitFor(() => {
|
||||
expect(discordMock.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(mattermostMock.send).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWorkflowRunFailure', () => {
|
||||
|
||||
@@ -619,7 +619,7 @@ export class WebhookService {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = [
|
||||
const messages: Promise<unknown>[] = [
|
||||
...(repository.private ? [] : [this.notifications.notify('community.releases', notification)]),
|
||||
this.notifications.notify('team.releases', notification),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { DateTime, Settings } from 'luxon';
|
||||
import { Constants } from 'src/constants';
|
||||
import { HolidayDto, IHolidaysInterface } from 'src/interfaces/holidays.interface';
|
||||
import { IZulipInterface } from 'src/interfaces/zulip.interface';
|
||||
import { ZulipService } from 'src/services/zulip.service';
|
||||
import { Mocked, afterEach, beforeEach, describe, expect, it, vitest } from 'vitest';
|
||||
|
||||
const { config } = vitest.hoisted(() => ({
|
||||
config: {
|
||||
zulip: {
|
||||
bot: { username: 'bot@example.com', apiKey: 'bot-key' },
|
||||
user: { username: 'human@example.com', apiKey: 'user-key' },
|
||||
realm: 'https://zulip.example.com',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vitest.mock('src/config', () => ({ getConfig: () => config }));
|
||||
|
||||
/**
|
||||
* Characterization tests: these pin the CURRENT holiday notice ZulipService sends and the rule that
|
||||
* decides whether tomorrow's holiday deserves one. If an assertion changes during a refactor, the
|
||||
* refactor drifted - fix the code, never the assertion.
|
||||
*/
|
||||
|
||||
const newHolidaysMock = (): Mocked<IHolidaysInterface> => ({
|
||||
getHolidays: vitest.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const newZulipMock = (): Mocked<IZulipInterface> => ({
|
||||
init: vitest.fn(),
|
||||
sendMessage: vitest.fn(),
|
||||
createEmote: vitest.fn(),
|
||||
});
|
||||
|
||||
/** A relevant holiday on the day after the frozen clock, unless overridden. */
|
||||
const newHoliday = (overrides: Partial<HolidayDto> = {}): HolidayDto => ({
|
||||
date: '2026-07-04',
|
||||
localName: 'Independence Day',
|
||||
name: 'Independence Day',
|
||||
countryCode: 'US',
|
||||
global: true,
|
||||
counties: null,
|
||||
launchYear: 1776,
|
||||
types: ['Public'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Freeze luxon's clock at the given UTC instant. */
|
||||
const setNow = (iso: string) => {
|
||||
const millis = DateTime.fromISO(iso, { zone: 'utc' }).toMillis();
|
||||
Settings.now = () => millis;
|
||||
};
|
||||
|
||||
const NOTICE = "Tomorrow is a federal holiday: Independence Day. There won't be any meetings tomorrow.";
|
||||
|
||||
describe('ZulipService', () => {
|
||||
let sut: ZulipService;
|
||||
let holidaysMock: Mocked<IHolidaysInterface>;
|
||||
let zulipMock: Mocked<IZulipInterface>;
|
||||
|
||||
const originalNow = Settings.now;
|
||||
const originalZone = Settings.defaultZone;
|
||||
|
||||
beforeEach(() => {
|
||||
// "Tomorrow" is resolved in the default zone, so pin it for deterministic dates.
|
||||
Settings.defaultZone = 'utc';
|
||||
// The cron fires at 22:00; this is the evening before Independence Day.
|
||||
setNow('2026-07-03T22:00:00.000Z');
|
||||
|
||||
holidaysMock = newHolidaysMock();
|
||||
zulipMock = newZulipMock();
|
||||
sut = new ZulipService(holidaysMock, zulipMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Settings.now = originalNow;
|
||||
Settings.defaultZone = originalZone;
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('notifyHoliday', () => {
|
||||
it("should look up US holidays for tomorrow's year", async () => {
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(holidaysMock.getHolidays).toHaveBeenCalledOnce();
|
||||
expect(holidaysMock.getHolidays).toHaveBeenCalledWith('US', 2026);
|
||||
});
|
||||
|
||||
it("should look up next year's holidays on New Year's Eve", async () => {
|
||||
setNow('2026-12-31T22:00:00.000Z');
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(holidaysMock.getHolidays).toHaveBeenCalledOnce();
|
||||
expect(holidaysMock.getHolidays).toHaveBeenCalledWith('US', 2027);
|
||||
});
|
||||
|
||||
it('should post the notice to the Holidays topic of the FUTO staff stream', async () => {
|
||||
holidaysMock.getHolidays.mockResolvedValue([newHoliday()]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledWith({
|
||||
stream: 2,
|
||||
topic: 'Holidays',
|
||||
content: "Tomorrow is a federal holiday: Independence Day. There won't be any meetings tomorrow.",
|
||||
});
|
||||
expect(zulipMock.sendMessage.mock.calls[0][0].stream).toBe(Constants.Zulip.Streams.FUTOStaff);
|
||||
expect(zulipMock.createEmote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use the holiday name, not the local name, in the notice', async () => {
|
||||
holidaysMock.getHolidays.mockResolvedValue([newHoliday({ name: 'Labor Day', localName: 'Labour Day' })]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledWith({
|
||||
stream: 2,
|
||||
topic: 'Holidays',
|
||||
content: "Tomorrow is a federal holiday: Labor Day. There won't be any meetings tomorrow.",
|
||||
});
|
||||
});
|
||||
|
||||
it('should send nothing when there is no holiday tomorrow', async () => {
|
||||
holidaysMock.getHolidays.mockResolvedValue([
|
||||
newHoliday({ date: '2026-07-03', name: 'Today' }),
|
||||
newHoliday({ date: '2026-07-05', name: 'The day after tomorrow' }),
|
||||
newHoliday({ date: '2026-09-07', name: 'Labor Day' }),
|
||||
]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(zulipMock.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send nothing when there are no holidays at all', async () => {
|
||||
holidaysMock.getHolidays.mockResolvedValue([]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(zulipMock.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('relevance', () => {
|
||||
const relevant: { reason: string; overrides: Partial<HolidayDto> }[] = [
|
||||
{ reason: 'a global public holiday', overrides: { global: true, counties: null, types: ['Public'] } },
|
||||
{ reason: 'a public holiday in Texas', overrides: { global: false, counties: ['US-TX'], types: ['Public'] } },
|
||||
{
|
||||
reason: 'a public holiday in Texas among other states',
|
||||
overrides: { global: false, counties: ['US-CA', 'US-TX', 'US-NY'], types: ['Public'] },
|
||||
},
|
||||
{
|
||||
reason: 'a global holiday that is public among other types',
|
||||
overrides: { global: true, counties: null, types: ['Bank', 'Public'] },
|
||||
},
|
||||
];
|
||||
|
||||
it.each(relevant)('should send a notice for $reason', async ({ overrides }) => {
|
||||
holidaysMock.getHolidays.mockResolvedValue([newHoliday(overrides)]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledWith({ stream: 2, topic: 'Holidays', content: NOTICE });
|
||||
});
|
||||
|
||||
const irrelevant: { reason: string; overrides: Partial<HolidayDto> }[] = [
|
||||
{
|
||||
reason: 'a global holiday that is not public',
|
||||
overrides: { global: true, counties: null, types: ['Observance'] },
|
||||
},
|
||||
{ reason: 'a global holiday without types', overrides: { global: true, counties: null, types: null } },
|
||||
{ reason: 'a global holiday with an empty type list', overrides: { global: true, counties: null, types: [] } },
|
||||
{
|
||||
reason: 'a public holiday in other states only',
|
||||
overrides: { global: false, counties: ['US-CA', 'US-NY'], types: ['Public'] },
|
||||
},
|
||||
{
|
||||
reason: 'a public holiday that is neither global nor in any state',
|
||||
overrides: { global: false, counties: null, types: ['Public'] },
|
||||
},
|
||||
{
|
||||
reason: 'a Texas holiday that is not public',
|
||||
overrides: { global: false, counties: ['US-TX'], types: ['Optional'] },
|
||||
},
|
||||
];
|
||||
|
||||
it.each(irrelevant)('should not send a notice for $reason', async ({ overrides }) => {
|
||||
holidaysMock.getHolidays.mockResolvedValue([newHoliday(overrides)]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(zulipMock.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip an irrelevant holiday and announce the relevant one on the same date', async () => {
|
||||
holidaysMock.getHolidays.mockResolvedValue([
|
||||
newHoliday({ name: 'Some observance', types: ['Observance'] }),
|
||||
newHoliday({ name: 'Independence Day' }),
|
||||
]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledWith({ stream: 2, topic: 'Holidays', content: NOTICE });
|
||||
});
|
||||
|
||||
it('should announce only the first relevant holiday when several fall on the same date', async () => {
|
||||
holidaysMock.getHolidays.mockResolvedValue([
|
||||
newHoliday({ name: 'Independence Day' }),
|
||||
newHoliday({ name: 'Another holiday' }),
|
||||
]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledWith({ stream: 2, topic: 'Holidays', content: NOTICE });
|
||||
});
|
||||
});
|
||||
|
||||
describe('zone', () => {
|
||||
// 22:00 on July 3rd in Texas is already 03:00 on July 4th in UTC.
|
||||
const lateEveningInTexas = '2026-07-04T03:00:00.000Z';
|
||||
|
||||
it('should resolve tomorrow in the default zone', async () => {
|
||||
Settings.defaultZone = 'America/Chicago';
|
||||
setNow(lateEveningInTexas);
|
||||
holidaysMock.getHolidays.mockResolvedValue([newHoliday({ date: '2026-07-04' })]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(holidaysMock.getHolidays).toHaveBeenCalledWith('US', 2026);
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.sendMessage).toHaveBeenCalledWith({ stream: 2, topic: 'Holidays', content: NOTICE });
|
||||
});
|
||||
|
||||
it('should treat the same instant as the holiday itself when the default zone is UTC', async () => {
|
||||
Settings.defaultZone = 'utc';
|
||||
setNow(lateEveningInTexas);
|
||||
holidaysMock.getHolidays.mockResolvedValue([newHoliday({ date: '2026-07-04' })]);
|
||||
|
||||
await sut.notifyHoliday();
|
||||
|
||||
expect(holidaysMock.getHolidays).toHaveBeenCalledWith('US', 2026);
|
||||
expect(zulipMock.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
beforeEach(() => {
|
||||
config.zulip.bot.apiKey = 'bot-key';
|
||||
config.zulip.user.apiKey = 'user-key';
|
||||
});
|
||||
|
||||
it('should initialise the Zulip clients exactly once, with both identities', async () => {
|
||||
await sut.init();
|
||||
|
||||
expect(zulipMock.init).toHaveBeenCalledOnce();
|
||||
expect(zulipMock.init).toHaveBeenCalledWith({
|
||||
bot: { username: 'bot@example.com', apiKey: 'bot-key' },
|
||||
user: { username: 'human@example.com', apiKey: 'user-key' },
|
||||
realm: 'https://zulip.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip initialisation when the bot key is the dev sentinel', async () => {
|
||||
config.zulip.bot.apiKey = 'dev';
|
||||
|
||||
await sut.init();
|
||||
|
||||
expect(zulipMock.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip initialisation when the user key is the dev sentinel', async () => {
|
||||
config.zulip.user.apiKey = 'dev';
|
||||
|
||||
await sut.init();
|
||||
|
||||
expect(zulipMock.init).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user