feat: typed zulip client on openapi-fetch (#636)

This commit is contained in:
Zack Pollard
2026-09-22 23:50:36 +01:00
committed by GitHub
parent 12f4bbfddd
commit f7cea83fb2
16 changed files with 25562 additions and 248 deletions
+3
View File
@@ -13,3 +13,6 @@ package-lock.json
yarn.lock
src/schema/migrations
# generated
src/generated
+18
View File
@@ -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
+1
View File
@@ -21,6 +21,7 @@ export default [
'**/pnpm-lock.yaml',
'**/package-lock.json',
'**/yarn.lock',
'src/generated',
],
},
js.configs.recommended,
+256 -193
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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>;
}
+479
View File
@@ -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([]);
});
});
});
+183
View File
@@ -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;
};
+180
View File
@@ -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',
});
});
});
});
+46 -40
View File
@@ -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 }),
});
}
}
+367 -1
View 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();
});
});
});
+30 -10
View File
@@ -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) {
+14
View File
@@ -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', () => {
+1 -1
View File
@@ -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),
];
+289
View File
@@ -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();
});
});
});