diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..04448c1d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [ '18', '20', '22', '24', 'latest' ] + name: Node ${{ matrix.node-version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + # Native accelerators (zlib-sync, erlpack, ...) are optionalDependencies and may + # fail to build on newer Node; that does not fail the install. sqlite3 is required + # for the migration tests and builds here. + - run: npm install + - run: npm run test:unit diff --git a/.gitignore b/.gitignore index 66c4c6c3..d7e6ce29 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ /node_modules/ /config/ src/functions/scnx-integration.js +instrument.js /.vscode/ /.idea/ diff --git a/developer-docs/README.md b/developer-docs/README.md index c0ed6228..8bedef4a 100644 --- a/developer-docs/README.md +++ b/developer-docs/README.md @@ -13,6 +13,8 @@ Start here if you want to add a new feature as a module: - [**Database models**](./database-models.md) - Sequelize `Model.init` pattern, `models-dir`, accessing models from events. - [**Localization**](./localization.md) - adding strings to `locales/en.json` and using `localize()`. +- [**Nickname manager**](./nickname-manager.md) - the shared service for changing member nicknames without modules + fighting each other. ## Configuration schema @@ -34,7 +36,7 @@ The string + embed format used in `allowEmbed` config fields. Canonical referenc ## Migration -- [**Migration**](./migration.md) - upgrading between major bot versions. +- [**Migration**](./migration.md) - writing database migrations so schema changes reach existing installs. ## Validation diff --git a/developer-docs/database-models.md b/developer-docs/database-models.md index 5cde1376..a2d7ea8f 100644 --- a/developer-docs/database-models.md +++ b/developer-docs/database-models.md @@ -65,18 +65,13 @@ All standard Sequelize methods are available: `findOne`, `findAll`, `findOrCreat ## Migrations -The bot calls `sequelize.sync()` at startup, which creates missing tables and adds missing columns automatically. **It -does not modify or remove existing columns.** If you change a column's type, rename it, or drop it, you have two -options: - -1. **Manual migration.** Use Sequelize's [umzug](https://github.com/sequelize/umzug) or write SQL by hand. Drop the - bot's table or run `ALTER TABLE` against your database. -2. **Bump the table name.** For breaking schema changes, rename `tableName` (e.g. `welcomer_User_v2`). The old table - stays in place for safety; you migrate data on the side. - -For non-trivial migrations across versions, the bot exposes `module.exports.migrationStart()` / `migrationEnd()` from -`main.js` - call these around long-running migration code so SIGTERM/SIGINT defers shutdown until the migration -finishes. +The bot calls `sequelize.sync()` at startup, which creates missing **tables**. **It does not add columns to existing +tables, nor modify or remove existing columns.** So whenever you add, rename, change, or drop a field on an existing +model, ship a migration alongside it so existing installs pick up the schema change. + +Migrations are file-based and run automatically on boot by an [Umzug](https://github.com/sequelize/umzug)-based runner - +you drop a file into your module's `migrations/` directory and the runner discovers it, applies it once, tracks it, and +backs up the affected tables first. See [migration.md](./migration.md) for the full guide. ## Associations diff --git a/developer-docs/migration.md b/developer-docs/migration.md index f9e49295..0a3e293b 100644 --- a/developer-docs/migration.md +++ b/developer-docs/migration.md @@ -6,346 +6,146 @@ This guide explains how to write safe database migrations for CustomDCBot module Sequelize's `db.sync()` (called in `main.js` at startup) creates tables that don't exist, but it **does not** add new columns to existing tables. If you add a new field to a model, existing databases will be missing that column and -queries will fail. +queries will fail. Migrations add the missing columns to existing installs. -Migrations solve this by reading existing data, recreating the table with the new schema, and re-inserting the data. +## How migrations work -## Where migrations run +Migrations are plain files in a `migrations/` directory inside your module, next to `models/` and `events/`. On every +boot, after models are loaded and `db.sync()` has run, the migration runner +(`src/functions/migrations/runMigrations.js`) discovers each module's `migrations/` directory, works out which +migrations are still pending, and runs them in order using [Umzug](https://github.com/sequelize/umzug). -Migrations go in your module's `events/botReady.js`, at the top of the `run` function - before any other logic. +You do **not** wire anything up yourself - dropping a correctly named file into `migrations/` is enough. The runner +also: -## The DatabaseSchemeVersion table +- tracks applied migrations in the shared `system_DatabaseSchemeVersion` table, so each migration runs at most once; +- takes a JSON backup of every table a migration declares (see [Backups](#backups)) before running it; +- defers bot shutdown while a migration is in progress, so a SIGTERM/SIGINT can't interrupt a half-applied schema + change. This is automatic - you do not call `migrationStart()` / `migrationEnd()` from migration code. -Every migration is tracked using the shared `DatabaseSchemeVersion` model. Before running a migration, check if it has -already been applied: +If any migration throws, the runner aborts the boot rather than letting the bot run on a partially migrated schema. -```js -const dbVersion = await client.models['DatabaseSchemeVersion'].findOne({ - where: { - model: 'your-module_YourModel', - version: 'V1' - } -}); -if (!dbVersion) { - // Run migration -} -``` +## File location and naming -After the migration completes, mark it as done: - -```js -await client.models['DatabaseSchemeVersion'].create({ - model: 'your-module_YourModel', - version: 'V1' -}); ``` - -The naming convention for `model` is `moduleName_ModelName` (e.g. `birthday_User`, `activity-streak_StreakUser`). - -## Migration pattern - -```js -const { - migrationStart, - migrationEnd -} = require('../../../main'); - -module.exports.run = async function (client) { - const dbVersion = await client.models['DatabaseSchemeVersion'].findOne({ - where: {model: 'your-module_YourModel'} - }); - if (!dbVersion) { - migrationStart(); - try { - client.logger.info('[your-module] Running V1 migration (adding newField)...'); - - // 1. Read existing data with EXPLICIT attributes (only columns that exist pre-migration) - const data = await client.models['your-module']['YourModel'].findAll({ - attributes: ['id', 'existingField1', 'existingField2'] - }); - - // 2. Drop and recreate the table with the new schema - await client.models['your-module']['YourModel'].sync({force: true}); - - // 3. Re-insert all data with the new field's default value - for (const row of data) { - await client.models['your-module']['YourModel'].create({ - id: row.id, - existingField1: row.existingField1, - existingField2: row.existingField2, - newField: false // default value for the new column - }); - } - - client.logger.info('[your-module] V1 migration complete.'); - await client.models['DatabaseSchemeVersion'].create({ - model: 'your-module_YourModel', - version: 'V1' - }); - } finally { - migrationEnd(); - } - } - - // ... rest of your botReady logic -}; +modules//migrations/__V.js ``` -## Critical rules +- `` is a label for the table(s) the migration touches, by convention `moduleName_Model` (e.g. + `levels_User`, `economy_Shop`). +- `__V` is the version. Migrations within a module run in filename order, so `__V1` runs before `__V2`. -### Always use explicit attributes in findAll +Examples: `modules/levels/migrations/levels_User__V1.js`, `modules/economy-system/migrations/economy_Shop__V1.js`. -```js -// WRONG - will try to SELECT the new column that doesn't exist yet -const data = await client.models['your-module']['YourModel'].findAll(); +## Migration file shape -// CORRECT - only selects columns that exist in the pre-migration table -const data = await client.models['your-module']['YourModel'].findAll({ - attributes: ['id', 'existingField1', 'existingField2'] -}); -``` +A migration exports an object with `up`, `down`, and an optional `tables` array. Both `up` and `down` receive Umzug's +context: `{sequelize, queryInterface, client}`. -Your model already defines the new field, so Sequelize will include it in the `SELECT` statement by default. Since the -column doesn't exist in the database yet, the query will crash. Always list only the columns that exist **before** your -migration. +```js +const {DataTypes} = require('sequelize'); -### Always wrap migrations in migrationStart/migrationEnd +const TABLE = 'levels_users'; -```js -const { - migrationStart, - migrationEnd -} = require('../../../main'); -``` +module.exports = { + // Tables to snapshot before this migration runs (see Backups). Optional but recommended. + tables: [TABLE], -Call `migrationStart()` before the migration begins and `migrationEnd()` when it finishes. **Always** use `try/finally` -to ensure `migrationEnd()` runs even if the migration throws an error. This prevents the bot from shutting down -mid-migration (which would cause data loss since `sync({force: true})` drops the table before recreating it). + up: async ({context: {queryInterface, sequelize}}) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); -### Always re-insert with explicit field mapping + if (!description.dailyMessages) { + await queryInterface.addColumn(TABLE, 'dailyMessages', { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false + }, {transaction}); + } + }); + }, -```js -// WRONG - may carry over unexpected fields or miss the new default -await client.models['your-module']['YourModel'].create(row); - -// CORRECT - explicit mapping with new field default -await client.models['your-module']['YourModel'].create({ - id: row.id, - existingField1: row.existingField1, - newField: false -}); + down: async ({context: {queryInterface, sequelize}}) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.dailyMessages) await queryInterface.removeColumn(TABLE, 'dailyMessages', {transaction}); + }); + } +}; ``` -### Mark the migration version after all data is re-inserted +> **Note:** the table name passed to `queryInterface` is the real SQL table name (e.g. `levels_users`), not the +> Sequelize model name. Check your model's `tableName` option. -The `DatabaseSchemeVersion` entry should be created **after** all data has been successfully migrated. If the migration -fails halfway, it will re-run on next startup (which is safe since it checks the version first). - -## Multiple migrations +## Critical rule: migrations must be idempotent -Migrations stack sequentially. Each one runs in order and assumes all previous migrations have already been applied. -This matters for which columns you list in `attributes`. +The runner always asks Umzug to run whatever it considers pending. On a **brand-new install**, `db.sync()` has already +created the table with the current schema (including your new column) before any migration runs. Your migration will +still execute, so it must not fail or double-apply when the change is already present. -### Adding a second migration later - -When a new release needs another schema change, add a new migration block **after** the existing one: +Guard every change with a `describeTable` check: ```js -// V1 migration (existing - added "hidden" field) -const dbVersion = await client.models['DatabaseSchemeVersion'].findOne({ - where: {model: 'your-module_YourModel'} -}); -if (!dbVersion) { - migrationStart(); - try { - const data = await client.models['your-module']['YourModel'].findAll({ - attributes: ['id', 'existingField1', 'existingField2'] - }); - await client.models['your-module']['YourModel'].sync({force: true}); - for (const row of data) { - await client.models['your-module']['YourModel'].create({ - id: row.id, - existingField1: row.existingField1, - existingField2: row.existingField2, - hidden: false - }); - } - await client.models['DatabaseSchemeVersion'].create({ - model: 'your-module_YourModel', - version: 'V1' - }); - } finally { - migrationEnd(); - } -} - -// V2 migration (new - added "priority" field) -const dbVersionV2 = await client.models['DatabaseSchemeVersion'].findOne({ - where: { - model: 'your-module_YourModel', - version: 'V2' - } -}); -if (!dbVersionV2) { - migrationStart(); - try { - // V1 has already run, so "hidden" exists in the table now - const data = await client.models['your-module']['YourModel'].findAll({ - attributes: ['id', 'existingField1', 'existingField2', 'hidden'] - }); - await client.models['your-module']['YourModel'].sync({force: true}); - for (const row of data) { - await client.models['your-module']['YourModel'].create({ - id: row.id, - existingField1: row.existingField1, - existingField2: row.existingField2, - hidden: row.hidden, - priority: 0 - }); - } - await client.models['DatabaseSchemeVersion'].upsert({ - model: 'your-module_YourModel', - version: 'V2' - }); - } finally { - migrationEnd(); - } +const description = await queryInterface.describeTable(TABLE).catch(() => ({})); +if (!description.newColumn) { + await queryInterface.addColumn(TABLE, 'newColumn', {/* ... */}, {transaction}); } ``` -V2's `attributes` includes `hidden` because V1 has already added it by the time V2 runs. +There is deliberately no "fresh install bypass". The runner cannot tell a brand-new table apart from an old table that +simply hasn't been migrated yet, so skipping on fresh installs would mark migrations applied without ever adding columns +to real upgrades. Idempotent bodies cost only a cheap `describeTable` call on fresh installs and do the right thing on +upgrades. -### Adding multiple fields in a single release +## Use incremental DDL, not table rebuilds -If you're adding multiple new fields at the same time (e.g. both `hidden` and `priority` in the same release), you only -need **one** migration. Don't create separate migrations for each field - just handle them all in one version bump: +Add and drop columns with `queryInterface.addColumn` / `removeColumn` inside a `sequelize.transaction`. Do **not** read +all rows, `sync({force: true})`, and re-insert - that drops the table and risks data loss if interrupted, and is no +longer the supported pattern. -```js -const dbVersion = await client.models['DatabaseSchemeVersion'].findOne({ - where: {model: 'your-module_YourModel'} -}); -if (!dbVersion) { - migrationStart(); - try { - const data = await client.models['your-module']['YourModel'].findAll({ - attributes: ['id', 'existingField1', 'existingField2'] - }); - await client.models['your-module']['YourModel'].sync({force: true}); - for (const row of data) { - await client.models['your-module']['YourModel'].create({ - id: row.id, - existingField1: row.existingField1, - existingField2: row.existingField2, - hidden: false, // new field 1 - priority: 0 // new field 2 - }); - } - await client.models['DatabaseSchemeVersion'].create({ - model: 'your-module_YourModel', - version: 'V1' - }); - } finally { - migrationEnd(); - } -} -``` +- **Add a column:** `describeTable` guard + `addColumn`. +- **Remove a column:** `describeTable` guard + `removeColumn`. +- **Rename a column:** guard on both names, then `renameColumn(TABLE, 'oldName', 'newName', {transaction})`. +- **Change a type / backfill values:** `addColumn` the new shape if missing, then run an `UPDATE` via + `queryInterface.sequelize.query(..., {transaction})` to convert existing values. -### Fresh installs vs. existing databases +Wrapping the work in a transaction means a failure rolls back cleanly and the migration stays pending for the next boot. -On a fresh install (no existing database), `db.sync()` in `main.js` creates all tables with all columns from the model -definition. The migration check finds no existing rows and no `DatabaseSchemeVersion` entry. The migration runs but -`findAll` returns an empty array, so it effectively just creates the version entry. This is fine - the migration is a -no-op on empty tables. +## Backups -### Removing or renaming fields +List the tables your migration touches in the exported `tables` array. Before the migration's `up()` runs, the runner +writes a JSON snapshot of each non-empty listed table to `${dataDir}/migration-backups/____.json` +and prunes all but the most recent snapshots. Empty tables are skipped. If a backup can't be written (e.g. no disk +space), the migration is aborted before any schema change is made. -If you need to **remove** a column, the same pattern works - just don't include the removed field in the re-insert step. -The `sync({force: true})` recreates the table from the model definition (which no longer has the field), so the column -disappears. +## Multiple migrations -If you need to **rename** a column, read the old column name in `attributes` and write to the new column name during -re-insert: +Add later schema changes as new files with the next version number; they stack on top of earlier ones. -```js -const data = await client.models['your-module']['YourModel'].findAll({ - attributes: ['id', 'oldFieldName'] -}); -await client.models['your-module']['YourModel'].sync({force: true}); -for (const row of data) { - await client.models['your-module']['YourModel'].create({ - id: row.id, - newFieldName: row.oldFieldName // renamed - }); -} ``` - -### Changing a field's type - -Same approach - read the old data, recreate the table, convert during re-insert: - -```js -const data = await client.models['your-module']['YourModel'].findAll({ - attributes: ['id', 'count'] // was STRING, now INTEGER -}); -await client.models['your-module']['YourModel'].sync({force: true}); -for (const row of data) { - await client.models['your-module']['YourModel'].create({ - id: row.id, - count: parseInt(row.count, 10) || 0 - }); -} +modules/your-module/migrations/your-module_Thing__V1.js +modules/your-module/migrations/your-module_Thing__V2.js # assumes V1 has already run ``` -### Multiple models in one module +Because `__V1` runs before `__V2`, a `V2` migration can rely on `V1`'s columns already existing. -If your module has multiple models that both need migrations, run them independently with separate version keys: +## Multiple models in one module -```js -// Model A migration -const dbVersionA = await client.models['DatabaseSchemeVersion'].findOne({ - where: {model: 'your-module_ModelA'} -}); -if (!dbVersionA) { - migrationStart(); - try { - // ... migrate ModelA ... - await client.models['DatabaseSchemeVersion'].create({ - model: 'your-module_ModelA', - version: 'V1' - }); - } finally { - migrationEnd(); - } -} +Give each model its own migration file with its own table prefix - they are tracked independently: -// Model B migration -const dbVersionB = await client.models['DatabaseSchemeVersion'].findOne({ - where: {model: 'your-module_ModelB'} -}); -if (!dbVersionB) { - migrationStart(); - try { - // ... migrate ModelB ... - await client.models['DatabaseSchemeVersion'].create({ - model: 'your-module_ModelB', - version: 'V1' - }); - } finally { - migrationEnd(); - } -} ``` - -Each model tracks its own version independently. They don't need to share version numbers. +modules/economy-system/migrations/economy_User__V1.js +modules/economy-system/migrations/economy_Shop__V1.js +modules/economy-system/migrations/economy_Cooldown__V1.js +``` ## Checklist Before submitting a migration: -- [ ] `findAll` uses explicit `attributes` listing only pre-migration columns -- [ ] Migration is wrapped in `migrationStart()` / `migrationEnd()` with `try/finally` -- [ ] New fields are explicitly set with their default value during re-insert -- [ ] `DatabaseSchemeVersion` entry is created **after** all data is re-inserted -- [ ] Version string follows the pattern `V1`, `V2`, etc. -- [ ] Model name follows the pattern `moduleName_ModelName` -- [ ] Migration runs at the top of `botReady.js` before any other module logic \ No newline at end of file +- [ ] File lives in `modules//migrations/` and is named `__V.js` +- [ ] Exports `{up, down}` (and `tables` for the snapshot) in the Umzug v3 shape +- [ ] `up` is idempotent - every change guarded by a `describeTable` check +- [ ] Schema changes use `addColumn`/`removeColumn`/`renameColumn` inside a `sequelize.transaction`, not table rebuilds +- [ ] `down` reverses `up` (also guarded), so the migration is reversible +- [ ] `tables` lists every table the migration writes to, so a backup is taken first \ No newline at end of file diff --git a/developer-docs/nickname-manager.md b/developer-docs/nickname-manager.md new file mode 100644 index 00000000..7fd99335 --- /dev/null +++ b/developer-docs/nickname-manager.md @@ -0,0 +1,182 @@ +# Nickname Manager + +Several modules want to change a member's nickname at the same time - the `nicknames` module applies a role-based +name, `afk-system` wraps it with `[AFK]`, `moderation` can rename muted/quarantined users, and so on. If each module +called `member.setNickname()` directly they would fight each other and overwrite each other's changes. + +The **Nickname Manager** is a single service that owns all bot-initiated nickname changes. Modules describe *what* they +want to contribute to a member's name; the manager renders the final string from every contribution and calls Discord's +`setNickname` once, only when the result actually differs from what Discord currently shows. + +It is available on the client as `client.nicknameManager` and is always present (core service). + +## The model + +A member's nickname is built from **contributions**. Each contribution has a **position** that decides where it lands: + +| Position | Effect | `value` | +|-----------------|----------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------| +| `base` | The core name. The highest-priority `base` wins. If no module supplies one, the manager derives it from the member's current nickname. | string | +| `prefix` | Text placed before the name. | string | +| `suffix` | Text placed after the name. | string | +| `wrap` | Wraps the whole rendered name (applied outermost). | function `(inner) => string` | +| `baseTransform` | Rewrites the base string itself, e.g. a name sanitizer. Applied to the base before prefixes/suffixes. *(advanced)* | function `(base, member) => string` | + +Each contribution also carries: + +- `source` *(string, required)* - a unique id for this contribution. A source can only have one contribution per + member at a time; setting it again replaces the previous one. +- `priority` *(number, default `0`)* - higher wins for `base`; for `prefix`/`suffix`/`wrap` it orders them from the + inside (closest to the base) out. +- `exclusive` *(boolean, default `false`)* - among `exclusive` contributions in the same position, only the + highest-priority one renders. Use this when two modules must not both decorate the same slot. +- `match` *(RegExp, optional)* - for an affix whose value changes over time (e.g. an activity streak ` 🔥3` → ` 🔥4`), + this tells the manager how to find and strip the previous value so it never doubles up. + +## Worked example + +Say a member's display name is `Alex`, and three modules contribute: + +| Source | Position | Value | Priority | +|-------------------------------|----------|-----------------------|----------| +| `nicknames` (role-based name) | `base` | `"Alex"` | 100 | +| `nicknames` (role prefix) | `prefix` | `"[Mod] "` | 10 | +| `levels` (rank tag) | `suffix` | `" | Lvl 5"` | 10 | +| `afk-system` (AFK marker) | `wrap` | `(s) => "[AFK] " + s` | 500 | + +The manager renders them in this order: + +``` + base Alex + + prefix → [Mod] Alex + + suffix → [Mod] Alex | Lvl 5 + wrap (outermost) → [AFK] [Mod] Alex | Lvl 5 +``` + +So Discord shows: **`[AFK] [Mod] Alex | Lvl 5`**. + +When the member stops being AFK, `afk-system`'s provider returns `null`, the `wrap` contribution disappears, the manager +re-renders to `[Mod] Alex | Lvl 5`, and (because that differs from the live nickname) writes it once. If nothing a +member sees would change, the manager renders the same string and makes **no** Discord call. + +Finally, the result is truncated to Discord's 32-character nickname limit (code-point aware, so an emoji on the boundary +is never split). + +## Registering a provider (recommended) + +The usual way to integrate is a **provider**: a function the manager calls for a member whenever that member needs +re-rendering. It returns a contribution, an array of contributions, or `null` for "nothing right now". + +Register it once, from your module's `onLoad.js` (see [Module setup](#module-setup)): + +```js +// modules/afk-system/onLoad.js +module.exports.onLoad = function (client) { + if (client.afkSystemProviderRegistered) return; // guard against double registration + client.afkSystemProviderRegistered = true; + + client.nicknameManager.registerProvider('afk', 'afk-system', async (member) => { + const AFKUser = client.models?.['afk-system']?.['AFKUser']; + if (!AFKUser) return null; + const session = await AFKUser.findOne({where: {userID: member.id}}); + if (!session) return null; // not AFK -> contribute nothing + return { + source: 'afk', + position: 'wrap', + value: (s) => '[AFK] ' + s, + priority: 500 + }; + }); +}; +``` + +`registerProvider(source, moduleName, fn)`: + +- `source` - unique key for this provider. +- `moduleName` - your module's name. The manager skips providers whose module is disabled and clears their + contributions, so a disabled module never affects nicknames. +- `fn(member)` - sync or async; returns a contribution / array / `null`. + +A provider can return several contributions at once - the `nicknames` module returns a `base` plus an optional role +`prefix`/`suffix`: + +```js +client.nicknameManager.registerProvider('nicknames', 'nicknames', async (member) => { + // ...resolve baseName and the matched role config... + const out = [{source: 'nicknames:base', position: 'base', value: baseName, priority: 100}]; + if (matched?.prefix) out.push({ + source: 'nicknames:rolePrefix', + position: 'prefix', + value: matched.prefix, + priority: 10 + }); + if (matched?.suffix) out.push({ + source: 'nicknames:roleSuffix', + position: 'suffix', + value: matched.suffix, + priority: 10 + }); + return out; +}); +``` + +## Triggering an update + +Providers run when the manager re-renders a member. Ask for a render with: + +```js +client.nicknameManager.attachMember(member); // give the manager a live GuildMember to write to +client.nicknameManager.requestUpdate(member.id); // schedule a (debounced) re-render + flush +``` + +Call these whenever your module changes something that affects the name (a member went AFK, a role changed, a streak +ticked). The manager re-runs every provider, renders, and writes to Discord only if the result changed. Writes are +serialized per member, so concurrent updates can't race. + +## Direct contributions (without a provider) + +If you'd rather push a contribution imperatively instead of recomputing it on every render, use `set` / `clear`: + +```js +client.nicknameManager.set(member.id, 'my-source', {position: 'suffix', value: ' ⭐', priority: 5}); +client.nicknameManager.clear(member.id, 'my-source'); +``` + +`set`/`clear` mark the member dirty and schedule a flush automatically (if the manager has a live member ref). + +## External edits + +`getLastRendered(memberId)` returns the last value the manager wrote. The `nicknames` module uses this to detect when a +user or moderator renames someone by hand (the live nickname differs from `lastRendered`) and persists that as the new +`base` via `persistExternalEditAsBase`, so manual edits stick instead of being reverted on the next render. + +## Module setup + +Run your registration once at startup by pointing `module.json` at an on-load file: + +```json +{ + "name": "afk-system", + "on-load-event": "onLoad.js", + "...": "..." +} +``` + +`onLoad.js` exports `module.exports.onLoad = function (client) { ... }`. Guard with a boolean flag on `client` (as in +the +examples above) so re-runs don't register the provider twice. + +## API summary + +| Method | Purpose | +|----------------------------------------------------------------------------|---------------------------------------------------------------------| +| `registerProvider(source, moduleName, fn)` | Add a provider that computes contributions on demand. | +| `unregisterProvider(source)` | Remove a provider. | +| `registerGlobalTransform(source, moduleName, {position, value, priority})` | A contribution applied to *every* member. | +| `unregisterGlobalTransform(source)` | Remove a global transform. | +| `set(memberId, source, contribution)` | Push a single contribution imperatively. | +| `clear(memberId, source)` | Remove a previously set contribution. | +| `clearAllForSource(source)` | Drop a source's contribution from every member. | +| `attachMember(member)` | Give the manager a live `GuildMember` so it can write during flush. | +| `requestUpdate(memberId)` | Schedule a re-render + flush. | +| `getLastRendered(memberId)` | The last nickname the manager wrote (detect external edits). | \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..396bc107 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,226 @@ +const globals = require('globals'); +const stylistic = require('@stylistic/eslint-plugin'); + +module.exports = [ + { + ignores: ['docs/', 'gen-doc/', 'node_modules/'] + }, + { + files: ['**/*.js'], + languageOptions: { + ecmaVersion: 12, + sourceType: 'commonjs', + globals: { + ...globals.node, + ...globals.commonjs + } + }, + plugins: { + '@stylistic': stylistic + }, + rules: { + 'no-unused-vars': 'error', + 'accessor-pairs': 'error', + 'array-callback-return': 'error', + 'arrow-body-style': 'off', + 'block-scoped-var': 'error', + 'camelcase': 'error', + 'capitalized-comments': 'off', + 'class-methods-use-this': 'error', + 'complexity': ['error', 75], + 'consistent-return': 'off', + 'consistent-this': 'error', + 'curly': 'off', + 'default-case': 'off', + 'default-case-last': 'error', + 'default-param-last': 'error', + 'dot-notation': 'off', + 'eqeqeq': 'error', + 'func-name-matching': 'error', + 'func-names': 'off', + 'func-style': ['error', 'declaration'], + 'grouped-accessor-pairs': 'error', + 'guard-for-in': 'off', + 'id-denylist': 'error', + 'id-length': 'off', + 'id-match': 'error', + 'init-declarations': 'off', + 'max-classes-per-file': 'error', + 'max-depth': 'off', + 'max-lines': ['error', {max: 1000, skipComments: true}], + 'max-lines-per-function': 'off', + 'max-nested-callbacks': 'error', + 'max-params': 'off', + 'max-statements': 'off', + 'new-cap': 'error', + 'no-alert': 'error', + 'no-array-constructor': 'error', + 'no-await-in-loop': 'off', + 'no-bitwise': ['error', {int32Hint: true}], + 'no-caller': 'error', + 'no-console': 'off', + 'no-constructor-return': 'error', + 'no-continue': 'off', + 'no-div-regex': 'error', + 'no-duplicate-imports': 'error', + 'no-else-return': 'off', + 'no-empty-function': 'off', + 'no-eq-null': 'error', + 'no-eval': 'error', + 'no-extend-native': 'error', + 'no-extra-bind': 'error', + 'no-extra-label': 'error', + 'no-implicit-globals': 'off', + 'no-implied-eval': 'error', + 'no-inline-comments': 'off', + 'no-invalid-this': 'error', + 'no-iterator': 'error', + 'no-label-var': 'error', + 'no-labels': 'error', + 'no-lone-blocks': 'error', + 'no-lonely-if': 'error', + 'no-loop-func': 'off', + 'no-loss-of-precision': 'error', + 'no-magic-numbers': 'off', + 'no-multi-assign': 'error', + 'no-multi-str': 'error', + 'no-negated-condition': 'off', + 'no-nested-ternary': 'error', + 'no-new': 'error', + 'no-new-func': 'error', + 'no-new-wrappers': 'error', + 'no-nonoctal-decimal-escape': 'error', + 'no-octal-escape': 'error', + 'no-param-reassign': 'off', + 'no-plusplus': 'off', + 'no-promise-executor-return': 'off', + 'no-proto': 'error', + 'no-restricted-exports': 'error', + 'no-restricted-globals': 'error', + 'no-restricted-imports': 'error', + 'no-restricted-properties': 'error', + 'no-restricted-syntax': 'error', + 'no-return-assign': 'off', + 'no-return-await': 'off', + 'no-script-url': 'error', + 'no-self-compare': 'error', + 'no-sequences': 'error', + 'no-shadow': ['error', {allow: ['err', 'resolve', 'reject']}], + 'no-template-curly-in-string': 'error', + 'no-ternary': 'off', + 'no-throw-literal': 'error', + 'no-undef-init': 'error', + 'no-undefined': 'error', + 'no-underscore-dangle': 'error', + 'no-unmodified-loop-condition': 'error', + 'no-unneeded-ternary': 'error', + 'no-unreachable-loop': 'error', + 'no-unsafe-optional-chaining': 'error', + 'no-unused-expressions': 'error', + 'no-use-before-define': 'off', + 'no-useless-backreference': 'error', + 'no-useless-call': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-concat': 'error', + 'no-useless-constructor': 'error', + 'no-useless-rename': 'error', + 'no-useless-return': 'off', + 'no-var': 'error', + 'no-void': 'error', + 'no-warning-comments': 'off', + 'object-shorthand': 'off', + 'one-var': 'off', + 'operator-assignment': ['error', 'never'], + 'prefer-arrow-callback': 'off', + 'prefer-const': 'error', + 'prefer-destructuring': 'off', + 'prefer-exponentiation-operator': 'error', + 'prefer-named-capture-group': 'error', + 'prefer-numeric-literals': 'error', + 'prefer-object-spread': 'error', + 'prefer-promise-reject-errors': 'off', + 'prefer-regex-literals': 'error', + 'prefer-rest-params': 'error', + 'prefer-spread': 'error', + 'prefer-template': 'off', + 'radix': ['error', 'as-needed'], + 'require-atomic-updates': 'off', + 'require-await': 'off', + 'require-unicode-regexp': 'off', + 'sort-imports': 'error', + 'sort-keys': 'off', + 'sort-vars': 'off', + 'strict': ['error', 'never'], + 'symbol-description': 'error', + 'vars-on-top': 'error', + 'yoda': ['error'], + + '@stylistic/array-bracket-newline': 'off', + '@stylistic/array-bracket-spacing': ['error', 'never'], + '@stylistic/array-element-newline': 'off', + '@stylistic/arrow-parens': 'off', + '@stylistic/arrow-spacing': ['error', {after: true, before: true}], + '@stylistic/block-spacing': 'off', + '@stylistic/brace-style': ['error', '1tbs', {allowSingleLine: true}], + '@stylistic/comma-dangle': 'error', + '@stylistic/comma-spacing': ['error', {after: true, before: false}], + '@stylistic/comma-style': ['error', 'last'], + '@stylistic/computed-property-spacing': ['error', 'never'], + '@stylistic/dot-location': ['error', 'property'], + '@stylistic/eol-last': 'off', + '@stylistic/function-call-spacing': 'error', + '@stylistic/function-call-argument-newline': ['error', 'consistent'], + '@stylistic/function-paren-newline': 'off', + '@stylistic/generator-star-spacing': 'error', + '@stylistic/implicit-arrow-linebreak': ['error', 'beside'], + '@stylistic/indent': ['error', 4, {SwitchCase: 1}], + '@stylistic/jsx-quotes': 'error', + '@stylistic/key-spacing': 'error', + '@stylistic/keyword-spacing': ['error', {after: true, before: true}], + '@stylistic/line-comment-position': 'off', + '@stylistic/linebreak-style': ['error', 'unix'], + '@stylistic/lines-around-comment': 'error', + '@stylistic/lines-between-class-members': 'error', + '@stylistic/max-len': 'off', + '@stylistic/max-statements-per-line': ['error', {max: 2}], + '@stylistic/multiline-comment-style': 'error', + '@stylistic/new-parens': 'error', + '@stylistic/newline-per-chained-call': 'off', + '@stylistic/no-confusing-arrow': 'error', + '@stylistic/no-extra-parens': 'off', + '@stylistic/no-extra-semi': 'error', + '@stylistic/no-floating-decimal': 'error', + '@stylistic/no-mixed-operators': 'off', + '@stylistic/no-multi-spaces': 'error', + '@stylistic/no-multiple-empty-lines': 'error', + '@stylistic/no-tabs': 'error', + '@stylistic/no-trailing-spaces': 'error', + '@stylistic/no-whitespace-before-property': 'error', + '@stylistic/nonblock-statement-body-position': 'error', + '@stylistic/object-curly-newline': 'error', + '@stylistic/object-curly-spacing': 'off', + '@stylistic/one-var-declaration-per-line': 'off', + '@stylistic/operator-linebreak': 'error', + '@stylistic/padded-blocks': 'off', + '@stylistic/padding-line-between-statements': 'error', + '@stylistic/quote-props': 'off', + '@stylistic/quotes': ['error', 'single', {allowTemplateLiterals: true}], + '@stylistic/rest-spread-spacing': ['error', 'never'], + '@stylistic/semi': 'error', + '@stylistic/semi-spacing': ['error', {after: true, before: false}], + '@stylistic/semi-style': ['error', 'last'], + '@stylistic/space-before-blocks': 'error', + '@stylistic/space-before-function-paren': 'off', + '@stylistic/space-in-parens': 'error', + '@stylistic/space-infix-ops': 'error', + '@stylistic/space-unary-ops': ['error', {nonwords: false, words: true}], + '@stylistic/spaced-comment': ['error', 'always'], + '@stylistic/switch-colon-spacing': 'error', + '@stylistic/template-curly-spacing': ['error', 'never'], + '@stylistic/template-tag-spacing': 'error', + '@stylistic/wrap-iife': 'error', + '@stylistic/wrap-regex': 'error', + '@stylistic/yield-star-spacing': 'error' + } + } +]; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..6e29bfec --- /dev/null +++ b/jest.config.js @@ -0,0 +1,15 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.js'], + rootDir: '.', + // On low-core CI runners Jest uses few workers, so one worker can run many + // suites back-to-back and accumulate heap until it OOMs. Recycle a worker + // once it grows past this limit to keep memory bounded. + workerIdleMemoryLimit: '768MB', + setupFiles: ['/src/discordjs-fix.js'], + moduleNameMapper: { + '^(?:\\.{1,2}/)+main$': '/tests/__stubs__/main.js', + '(?:^|/)src/functions/localize$': '/tests/__stubs__/localize.js', + '^\\./localize$': '/tests/__stubs__/localize.js' + } +}; diff --git a/locales/en.json b/locales/en.json index 24e29986..427cbd95 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,132 +1,4 @@ { - "main": { - "startup-info": "SCNX-CustomBot v2 - Log-Level: %l", - "missing-moduleconf": "Missing moduleConfig-file. Automatically disabling all modules and overwriting modules.json later", - "sync-db": "Synced database", - "login-error": "Bot could not log in. Error: %e", - "login-error-token": "Bot could not log in because the provided token is invalid. Please update your token.", - "login-error-intents": "Bot could not log in because the intents were not enabled correctly. Please enable \"PRESENCE INTENT\", \"SERVER MEMBERS INTENT\" and \"MESSAGE CONTENT INTENT\" in your Discord-Developer-Dashboard: %url", - "not-invited": "Please invite the bot to your Discord server before continuing: %inv", - "require-code-grant-active": "You might be unable to invite your bot to your server as you have enabled the \"Require public code grant\" option in your Discord Developer Dashboard. Please disable this option: %d", - "interactions-endpoint-active": "You bot will be unable to respond to interactions, because the field \"Interactions Endpoint URL\" has a value in your Discord Developer Dashboard. Please remove any content from this field and restart your bot: %d", - "logged-in": "Bot logged in as %tag and is now online.", - "logchannel-wrong-type": "There is no Log-Channel set or it has the wrong type (only text-channels are supported).", - "config-check-failed": "Configuration-Check failed. You can find more information in your log. The bot exited.", - "bot-ready": "The bot initiated successfully and is now listening to commands", - "no-command-permissions": "Could not update server commands. Please give us permissions to performe this critical action: %inv", - "perm-sync": "Synced permissions for /%c", - "perm-sync-failed": "Failed to synced permissions for /%c: %e", - "loading-module": "Loading module %m", - "hidden-module": "Module %m is hidden, meaning that it is not available. Skipping…", - "module-disabled": "Module %m is disabled", - "command-loaded": "Loaded command %d/%f", - "command-dir": "Loading commands in %d/%f", - "global-command-sync": "Synced global application commands", - "guild-command-sync": "Synced server application commands", - "guild-command-no-sync-required": "Server application commands are up to date - no syncing required", - "global-command-no-sync-required": "Global application commands are up to date - no syncing required", - "event-loaded": "Loaded events %d/%f", - "event-dir": "Loading events in %d/%f", - "model-loaded": "Loaded database model %d/%f", - "model-dir": "Loading database model in %d/%f", - "loaded-cli": "Loaded API-Action %c in %p", - "channel-lock": "Locked channel", - "channel-unlock": "Unlocked channel", - "channel-unlock-data-not-found": "Unlocking channel with ID %c failed because it was never locked (which is weird to begin with).", - "module-disable": "Module %m got disabled because %r", - "migrate-success": "Migration from %o to %m finished successfully.", - "migrate-start": "Migration from %o to %m started... Please do not stop the bot", - "shutdown-deferred": "Shutdown requested but a database migration is in progress. Will shut down after migration completes.", - "shutdown-after-migration": "Migration complete, proceeding with shutdown.", - "uncaught-exception": "Uncaught exception: %e — continuing execution.", - "unhandled-rejection": "Unhandled promise rejection: %e — continuing execution.", - "discord-error": "Discord.js error: %e", - "shard-error": "Discord shard error: %e", - "shard-disconnect": "Disconnected from Discord (close event code: %c). Reconnection will be attempted automatically.", - "shard-reconnecting": "Reconnecting to Discord…", - "db-connect-error": "Could not connect to the database: %e — the bot will now exit.", - "cli-command-error": "CLI command error: %e", - "discord-api-error": "Could not reach the Discord API during startup: %e — some checks were skipped." - }, - "reload": { - "reloading-config": "Reloading configuration…", - "reloading-config-with-name": "User %tag is reloading the configuration…", - "reloaded-config": "Configuration reloaded successfully.\nOut of %totalModules modules, %enabled were enabled, %configDisabled were disabled because their configuration was wrong.", - "reload-failed": "Configuration reloaded failed. Bot shutting down.", - "reload-successful-syncing-commands": "Configuration reloaded successfully, syncing commands, to make sure permissions are up-to-date…", - "reload-failed-message": "**FAILED**\n```%r```\n**Please read your log to find more information**\nThe bot will kill itself now, bye :wave:", - "command-description": "Reloads the configuration" - }, - "config": { - "checking-config": "Checking configurations...", - "done-with-checking": "Done with checking. Out of %totalModules modules, %enabled were enabled, %configDisabled were disabled because their configuration was wrong.", - "creating-file": "Config %m/%f does not exist - I'm going to create it, please stand by...", - "checking-of-field-failed": "An error occurred while checking the content of field \"%fieldName\" in %m/%f", - "saved-file": "Configuration-File %f in %m was saved successfully.", - "moduleconf-regeneration": "Regenerating module configuration, no settings will be overwritten, don't worry.", - "moduleconf-regeneration-success": "Module configuration regeneration successfully finished.", - "channel-not-found": "Channel with ID \"%id\" could not be found", - "user-not-found": "User with ID \"%id\" could not be found", - "channel-not-on-guild": "Channel with ID \"%id\" is not on your server", - "channel-invalid-type": "Channel with ID \"%id\" has a type that can not be used for this field", - "role-not-found": "Role with ID \"%id\" could not be found on your server", - "config-reload": "Reloading all configuration..." - }, - "helpers": { - "timestamp": "%dd.%mm.%yyyy at %hh:%min", - "you-did-not-run-this-command": "You did not run this command. If you want to use the buttons, try running the command yourself.", - "next": "Next", - "back": "Back", - "toggle-data-fetch-error": "SC Network Release: Toggle-Data could not be fetched", - "toggle-data-fetch": "SC Network Release: Toggle-Data fetched successfully", - "duration-just-now": "just now", - "duration-minute": "%i minute", - "duration-minutes": "%i minutes", - "duration-hour": "%i hour", - "duration-hours": "%i hours", - "duration-day": "%i day", - "duration-days": "%i days", - "duration-month": "%i month", - "duration-months": "%i months", - "duration-year": "%i year", - "duration-years": "%i years" - }, - "command": { - "startup": "The bot is currently starting up. Please try again in a few minutes.", - "not-found": "Command not found", - "used": "%tag (%id) used command /%c", - "message-used": "%tag (%id) used command %p%c", - "execution-failed": "Execution of command /%c %g %s failed (Tracing: %t): %e", - "message-execution-failed": "Execution of command %p%c failed (Tracing: %t): %e", - "wrong-guild": "This command is only available on the server **%g**.", - "autcomplete-execution-failed": "Execution of auto-complete on command /%c %g %s with option %f failed: %e", - "execution-failed-message": "## 🔴 Command execution failed 🔴\nThis usually happens either due to misconfiguration or due to an error on our site. Please send a screenshot of this message in #support on the [ScootKit Discord](https://scootk.it/dc-en), to resolve this.\n\n### Internal Tracing ID\n`%t`\n### Debugging-Information\n```%e```", - "error-giving-role": "An error occurred when trying to give you your roles ):\nPlease ask the server administrators to confirm that the highest role of the bot is above the role that the bot is supposed to assign.", - "description-too-long": "The following command description of %c was too long to sync: %s", - "module-disabled": "This command is part of the \"%m\" which is disabled. This can either be intended by the server-admins (and slash-commands haven't synced yet) or this could be caused by a configuration error. Please check (or ask the admins) to check the bot's configuration and logs for details.", - "command-disabled": "This command is currently disabled by the server configuration. If you believe this is an error, please contact a server administrator." - }, - "help": { - "bot-info-titel": "ℹ️ Bot-Info", - "bot-info-description": "This bot is part of [SCNX](https://scnx.xyz/de?ref=custombot_help_embed), a plattform from [ScootKit](https://scootkit.net) allowing the creation of fully customizable for Discord communities, and is being hosted for \"%g\".", - "stats-title": "📊 Stats", - "stats-content": "Active modules: %am\nRegistered commands: %rc\nBot-Version: %v\nRunning on server %si\n[Server-Plan](https://scnx.xyz/plan): %pl\nLast restart: %lr\nLast reload: %lR", - "command-description": "Show every commands", - "slash-commands-title": "Slash-Commands", - "select-module-placeholder": "Select a module to view its commands", - "select-module-hint": "👇 Use the dropdown below to browse commands by module.", - "back-to-overview": "Back to overview", - "modules-overview": "📋 Modules & Commands", - "built-in-description": "Core commands built into the bot", - "custom-commands-label": "Custom Commands", - "custom-commands-description": "User-created custom slash commands" - }, - "bot-feedback": { - "command-description": "Send feedback about the bot to the bot developer", - "submitted-successfully": "Thanks so much for your feedback! It has been carefully recorded and our team will review it soon. If we have any questions, we may contact you via DM (or if you are on our [Support Server]() we'll open a ticket). Thank you for making [CustomBots]() better for everyone <3\n\nYour feedback is subject to our [Terms of service]() and [Privacy Policy]().", - "failed-to-submit": "Sorry, but I couldn't send your feedback to our staff. This could be, because you got blocked or because of some server issue we are having. You can always report bugs and submit feedback in our [Feature-Board](https://features.sc-network.net). Thank you.", - "feedback-description": "Your feedback. Make sure it's neutral, constructive and helpful" - }, "admin-tools": { "position": "%i has the position %p.", "position-changed": "Changed %i's position to %p.", @@ -177,165 +49,112 @@ "users-trying-to-manage-higher-role": "Your highest role, %t, is not below %e. To manage a user's role, you the role you are managing needs to be below your highest role.", "audit-log-role-ban": "[admin-tools] User banned for receiving the \"%r\" role. Reason: %reason" }, - "welcomer": { - "channel-not-found": "[welcomer] Channel not found: %c", - "welcome-yourself-error": "Welcome, nice to meet you! This button is reversed for a special member of this server who want's to say \"Hi\" to you ^^" - }, - "months": { - "1": "January", - "2": "February", - "3": "March", - "4": "April", - "5": "May", - "6": "June", - "7": "July", - "8": "August", - "9": "September", - "10": "October", - "11": "November", - "12": "December" - }, - "levels": { - "leaderboard-channel-not-found": "Leaderboard-Channel not found or wrong type", - "leaderboard-notation": "%p. %u: Level %l - %xp XP", - "list-location": "[Level System] The live leaderboard is currently located here: %l. Delete the message and restart the bot, to re-send it.", - "leaderboard": "Leaderboard", - "no-user-on-leaderboard": "Can't generate a leaderboard, because no one has any XP which is odd, but that's how it is ¯\\_(ツ)_/¯", - "and-x-other-users": "and %uc other users", - "level": "Level %l", - "users": "Users", - "leaderboard-command-description": "Shows the leaderboard of this server", - "leaderboard-sortby-description": "How to sort the leaderboard (default: %d)", - "profile-command-description": "Shows the profile of you or an an user", - "profile-user-description": "User to see the profile from (default: you)", - "please-send-a-message": "Please send some messages before I can show you some data", - "no-role": "None", - "are-you-sure-you-want-to-delete-user-xp": "Okay, do you really want to screw with %u? If you hate them so much, feel free to run `/manage-levels reset-xp confirm:True user:%ut` to run this irreversible action.", - "are-you-sure-you-want-to-delete-server-xp": "Do you really want to delete all XP and Levels from this server? This action is irreversible and everyone on this server will hate you. Decided that it's worth it? Enter `/manage-levels reset-xp confirm:True`", - "user-not-found": "User not found", - "user-deleted-users-xp": "%t deleted the XP of the user with id %u", - "removed-xp-successfully": "`Removed %u's XP and level successfully.`", - "deleted-server-xp": "%u deleted the XP of all users", - "successfully-deleted-all-xp-of-users": "Successfully deleted all the XP of all users", - "cheat-no-profile": "This user doesn't have a profile (yet), please force them to write a message before trying to betrayal your community by manipulating level scores.", - "manipulated": "%u manipulated the XP of %m to %v (level %l)", - "successfully-changed": "Successfully edited the XP of %u - they are now **level %l** with **%x XP**.\nRemember, every change you make destroys the experience of other users on this server as the levelsystem isn't fair anymore.", - "edit-xp-command-description": "Manage the levels of your server", - "negative-xp": "This user would have a negative XP value which is not possible", - "negative-level": "This user would have a level below one which is not possible", - "xp-out-of-range": "This XP value is too large. Please choose a value below 1,000,000,000,000.", - "level-out-of-range": "This level value is too large. Please choose a value below 1,000,000.", - "reset-xp-description": "Reset the XP of a user or of the whole server", - "reset-xp-user-description": "User to reset the XP from (default: whole server)", - "reset-xp-confirm-description": "Do you really want to delete the data?", - "edit-xp-user-description": "User to edit", - "edit-xp-value-description": "New XP value of the user", - "edit-xp-description": "Betrays your community and edits a user's XP", - "no-custom-formula": "No valid custom formula was entered. Using default formula.", - "invalid-custom-formula": "Invalid custom formula was entered. Please either fix the syntax of your custom formula or remove the value of the custom formula field.", - "role-factors-total": "Multiplied together, the user receives **%f times more XP** for every message.", - "edit-level-description": "Betrays your community and edits a user's levels", - "random-messages-enabled-but-non-configured": "You have random messages enabled, but have non configured. Ignoring config.randomMessages configuration.", - "granted-rewards-audit-log": "Updated roles to make sure, they have the level role they need" - }, - "team-list": { - "channel-not-found": "Could not find channel with ID %c or the channel has a wrong type (only text-channels supported)", - "role-not-found": "Could not find role with ID %r", - "no-users-with-role": "No users on this server have the %r role yet.", - "no-roles-selected": "No roles listed yet.", - "offline": "Offline", - "dnd": "Do not disturb", - "idle": "Away", - "online": "Online" - }, - "ping-on-vc-join": { - "channel-not-found": "Notify channel %c not found", - "could-not-send-pn": "Could not send PN to %m" - }, - "suggestions": { - "suggestion-not-found": "Suggestion not found", - "updated-suggestion": "Successfully updated suggestion", - "suggest-description": "Create and comment on suggestions", - "suggest-content": "Content you want to suggest", - "loading": "A wild new suggestion appeared, loading..", - "manage-suggestion-command-description": "Manage suggestions as an admin", - "manage-suggestion-accept-description": "Accepts a suggestion", - "manage-suggestion-deny-description": "Denies a suggestion", - "manage-suggestion-id-description": "ID of the suggestion", - "manage-suggestion-comment-description": "Explain why you made this choice" + "afk-system": { + "command-description": "Manage your AFK-Status on this server", + "end-command-description": "End your current AFK-Session", + "start-command-description": "Start a new AFK-Session", + "reason-option-description": "Explain why you started this session", + "autoend-option-description": "If enabled, the bot will auto-end your AFK Session when your write a message (default: enabled)", + "no-running-session": "You don't have any session running.", + "already-running-session": "You already have an AFK-Session running, try ending it with `/afk-system end`.", + "afk-nickname-change-audit-log": "Updated user nickname because they started an AFK-Session", + "can-not-edit-nickname": "Can not edit nickname of %u: %e" }, "auto-delete": { "could-not-fetch-channel": "Could not fetch channel with ID %c", "could-not-fetch-messages": "Could not fetch messages from channel with ID %c" }, - "auto-thread": { - "thread-create-reason": "This thread got created, because you configured auto-thread to do so" - }, "auto-messager": { "channel-not-found": "Channel with ID %id not found" }, - "polls": { - "what-have-i-votet": "What have I voted?", - "vote": "Vote!", - "vote-this": "Click on this option to place your vote here", - "voted-successfully": "Successfully voted. Thanks for your participation.", - "not-voted-yet": "You have not voted yet, so I can't show you what you voted.", - "you-voted": "You have voted for **%o**.", - "remove-vote": "Remove my vote", - "removed-vote": "Your vote was removed successfully.", - "change-opinion": "You can change your opinion anytime by just selecting something else above the button you just clicked.", - "command-poll-description": "Create and end polls", - "command-poll-create-description": "Create a new poll", - "command-poll-end-description": "Ends an existing poll", - "command-poll-end-msgid-description": "ID of the poll", - "command-poll-create-description-description": "Topic / Description of this poll", - "command-poll-create-channel-description": "Channel in which the poll should get created", - "command-poll-create-option-description": "Option number %o", - "command-poll-create-endAt-description": "Duration of the poll (if not set the poll will not end automatically)", - "command-poll-create-public-description": "If enabled (disabled by default) the votes of users will be displayed publicly", - "created-poll": "Successfully created poll in %c.", - "not-found": "Poll could not be found", - "no-votes-for-this-option": "Nobody voted this option yet", - "ended-poll": "Poll ended successfully", - "view-public-votes": "View current voters", - "not-public": "This poll does not appear to be public, no results can be displayed.", - "poll-private": "🔒 This poll is **anonymous**, meaning that no one can see what you voted (not even the admins).", - "poll-public": "🔓 This poll is **public**, meaning that everyone can see what you voted.", - "not-text-channel": "You need to select a text-channel that is not an announcement-channel." - }, - "channel-stats": { - "audit-log-reason-interval": "Updated channel because of interval", - "audit-log-reason-startup": "Updated channel because of startup", - "not-voice-channel-info": "Channel \"%c\" (%id) is a %t and not a voice-channel as recommended" + "auto-thread": { + "thread-create-reason": "This thread got created, because you configured auto-thread to do so" }, - "info-commands": { - "info-command-description": "Find information about parts of this server", - "command-userinfo-description": "Find more information about a user on this server", - "argument-userinfo-user-description": "User you want to see information about (default: you)", - "command-roleinfo-description": "Find more information about a role on this server", - "argument-roleinfo-role-description": "Role you want to see information about", - "command-channelinfo-description": "Find more information about a channel on this server", - "argument-channelinfo-channel-description": "Channel you want to see information about", - "command-serverinfo-description": "Find more information about this server", - "information-about-role": "Information about the role %r", - "hoisted": "Hoisted", - "mentionable": "Mentionable", - "managed": "Managed", - "information-about-channel": "Information about the channel %c", - "information-about-user": "Information about the user %u", - "information-about-server": "Information about %s", - "boostLevel": "Level", - "boostCount": "Boosts", - "userCount": "Users", - "memberCount": "Members", - "onlineCount": "Online", - "textChannel": "Text", - "voiceChannel": "Voice", - "categoryChannel": "Categories", - "otherChannel": "Other", - "total-invites": "Total", - "active-invites": "Active", - "left-invites": "Left" + "betterstatus": { + "command-description": "Change the bot's status", + "command-disabled": "The /status command is not enabled. An administrator needs to enable it in the betterstatus module configuration.", + "text-description": "The status text to display", + "activity-type-description": "The activity type (Playing, Watching, etc.)", + "bot-status-description": "The bot's online status (Online, Idle, DND)", + "streaming-link-description": "Streaming URL (only used when activity type is Streaming)", + "status-changed": "Bot status has been changed to: %s" + }, + "birthdays": { + "channel-not-found": "[birthdays] Channel not found: %c", + "sync-error": "[birthdays] %u's state was set to \"sync\", but there was no syncing candidate, so I disabled the synchronization", + "age-hover": "%a years old", + "sync-enabled-hover": "Birthday synchronized", + "verified-hover": "Birthday verified", + "no-bd-this-month": "No birthdays this month ):", + "no-birthday-set": "You don't currently have a registered birthday on this server. Set a birthday with `/birthday set`.", + "birthday-status": "Your birthday is currently set to **%dd.%mm%yyyy**%age.", + "your-age": "which means that you are **%age** years old", + "sync-on": "Your birthday is being synced via your [SC Network Account](https://sc-network.net/dashboard).", + "sync-off": "Your birthday is set locally on this server and will not be synchronized", + "no-sync-account": "It seems like you either don't have an [SC Network Account]() or you haven't entered any information about your birthday in it yet.", + "auto-sync-on": "It seems that you have autoSync in your [SC Network Account]() enabled. This means that your birthday will be synchronized all the time on every server. [Learn more]().\nYour birthday isn't showing up? It can take up to 24 hours (usually it's less than two hours) for it to be synced, so stay calm and wait just a bit longer.", + "enabled-sync": "Successfully set. The synchronization is now enabled :+1:", + "disabled-sync": "Successfully set. The synchronization is disabled, you can now change or remove your birthday from this server.", + "delete-but-sync-is-on": "You currently have sync enabled. Please disable sync to delete your birthday.", + "deleted-successfully": "Birthday deleted successfully.", + "only-sync-allowed": "This server only allows synchronization of your birthday with a [SC Network Account]()", + "invalid-date": "Invalid date provided", + "against-tos": "You have to be at least 13 years old to use Discord. Please read Discord's [Terms of Service]() and if you are under the age of 13 please [delete your account]() to comply with Discord's [Terms of Service]() and wait %waitTime (or for the age for your country, listed [here]()) years before creating a new account.", + "too-old": "It seems like you are too old to be alive", + "command-description": "View, edit and delete your birthday", + "status-command-description": "Shows the current status of your birthday", + "sync-command-description": "Manage the synchronization on this server", + "sync-command-action-description": "Action which should be performed on your synchronization", + "sync-command-action-enable-description": "Enable synchronization", + "sync-command-action-disable-description": "Disable synchronization", + "set-command-description": "Sets your birthday", + "set-command-day-description": "Day of your birthday", + "set-command-month-description": "Month of your birthday", + "set-command-year-description": "Year of your birthday", + "delete-command-description": "Deletes your birthday from this server", + "migration-happening": "Database-Schema not up-to-date. Migration database... This could take a while. Do not restart your bot to avoid data loss.", + "migration-done": "Successfully migrated database to newest version.", + "birthday-locked": "Your birthday has been locked by an admin and cannot be edited.", + "locked-indicator": "Locked by admin", + "manage-command-description": "Manage user birthdays (admin)", + "admin-set-description": "Set a user's birthday", + "admin-remove-description": "Remove a user's birthday", + "admin-lock-description": "Lock a user's birthday from editing", + "admin-unlock-description": "Unlock a user's birthday", + "admin-user-description": "The user to manage", + "admin-birthday-set": "Birthday for %u set to %dd.%mm", + "admin-no-birthday": "%u has no birthday set", + "admin-birthday-removed": "Birthday for %u has been removed", + "admin-birthday-locked": "Birthday for %u has been locked", + "admin-birthday-unlocked": "Birthday for %u has been unlocked", + "set-birthday-button": "Set your birthday", + "modal-title": "Set your birthday", + "year-placeholder": "Optional, e.g. 2000", + "upcoming-subcommand-description": "Shows upcoming birthdays in the next N days", + "upcoming-command-days-description": "How many days to look ahead", + "upcoming-embed-title": "Upcoming birthdays in the next %n days", + "no-upcoming-birthdays": "No upcoming birthdays in the next %n days.", + "upcoming-today": "today", + "upcoming-tomorrow": "tomorrow", + "upcoming-in-x-days": "in %n days", + "turning-age": "turning %a" + }, + "boostTier": { + "0": "None", + "1": "Level 1", + "2": "Level 2", + "3": "Level 3" + }, + "bot-feedback": { + "command-description": "Send feedback about the bot to the bot developer", + "submitted-successfully": "Thanks so much for your feedback! It has been carefully recorded and our team will review it soon. If we have any questions, we may contact you via DM (or if you are on our [Support Server]() we'll open a ticket). Thank you for making [CustomBots]() better for everyone <3\n\nYour feedback is subject to our [Terms of service]() and [Privacy Policy]().", + "failed-to-submit": "Sorry, but I couldn't send your feedback to our staff. This could be, because you got blocked or because of some server issue we are having. You can always report bugs and submit feedback in our [Feature-Board](https://features.sc-network.net). Thank you.", + "feedback-description": "Your feedback. Make sure it's neutral, constructive and helpful" + }, + "channel-stats": { + "audit-log-reason-interval": "Updated channel because of interval", + "audit-log-reason-startup": "Updated channel because of startup", + "not-voice-channel-info": "Channel \"%c\" (%id) is a %t and not a voice-channel as recommended" }, "channelType": { "GUILD_TEXT": "Text-Channel", @@ -351,125 +170,178 @@ "GROUP_DM": "Group-Direct-Message", "UNKNOWN": "Unknown" }, - "stagePrivacy": { - "PUBLIC": "Publicly accessible", - "GUILD_ONLY": "Only server members can join" - }, - "guildVerification": { - "0": "None", - "1": "Low", - "2": "Medium", - "3": "High", - "4": "Very high" + "color-me": { + "create-log-reason": "%user redeemed their boosting-rewards by requesting the creation of this role", + "edit-log-reason": "%user edited their boosting-reward-role", + "delete-unboost-log-reason": "%user stopped boosting, so their role got deleted", + "delete-manual-log-reason": "%user deleted their role manually", + "command-description": "Request a Custom role as a reward for boosting. This has a cooldown of 24 hours", + "manage-subcommand-description": "Create or edit your custom role", + "name-option-description": "The name of your custom role", + "color-option-description": "The color of your custom role", + "remove-subcommand-description": "Remove your custom role", + "icon-option-description": "Your role-icon", + "confirm-option-remove-description": "Do you really want to delete your custom role? This will not reset any running cooldowns" }, - "boostTier": { - "0": "None", - "1": "Level 1", - "2": "Level 2", - "3": "Level 3" + "command": { + "startup": "The bot is currently starting up. Please try again in a few minutes.", + "not-found": "Command not found", + "used": "%tag (%id) used command /%c", + "message-used": "%tag (%id) used command %p%c", + "execution-failed": "Execution of command /%c %g %s failed (Tracing: %t): %e", + "message-execution-failed": "Execution of command %p%c failed (Tracing: %t): %e", + "wrong-guild": "This command is only available on the server **%g**.", + "autcomplete-execution-failed": "Execution of auto-complete on command /%c %g %s with option %f failed: %e", + "execution-failed-message": "## 🔴 Command execution failed 🔴\nThis usually happens either due to misconfiguration or due to an error on our site. Please send a screenshot of this message in #support on the [ScootKit Discord](https://scootk.it/dc-en), to resolve this.\n\n### Internal Tracing ID\n`%t`\n### Debugging-Information\n```%e```", + "error-giving-role": "An error occurred when trying to give you your roles ):\nPlease ask the server administrators to confirm that the highest role of the bot is above the role that the bot is supposed to assign.", + "role-confirm-header": "Review your role changes below, adjust the selection if needed, and submit to confirm.", + "role-confirm-no-change": "ℹ️ Your roles will not change with the current selection.", + "role-confirm-will-add": "✅ Will be added: %r", + "role-confirm-will-remove": "❌ Will be removed: %r", + "role-confirm-result-noop": "ℹ️ Your roles were not changed.", + "role-confirm-result-added": "✅ Added: %r", + "role-confirm-result-removed": "❌ Removed: %r", + "role-confirm-button-apply": "Confirm changes", + "role-confirm-button-cancel": "Cancel", + "role-confirm-cancelled": "ℹ️ Cancelled. Your roles were not changed.", + "description-too-long": "The following command description of %c was too long to sync: %s", + "module-disabled": "This command is part of the \"%m\" which is disabled. This can either be intended by the server-admins (and slash-commands haven't synced yet) or this could be caused by a configuration error. Please check (or ask the admins) to check the bot's configuration and logs for details.", + "command-disabled": "This command is currently disabled by the server configuration. If you believe this is an error, please contact a server administrator." }, - "temp-channels": { - "removed-audit-log-reason": "Removed temp channel, because no one was in it", - "permission-update-audit-log-reason": "Updated permissions, to make sure only people in the VC can see the no-mic-channel", - "created-audit-log-reason": "Created Temp-Channel for %u", - "move-audit-log-reason": "Moved user to their voice channel", - "no-mic-channel-topic": "Welcome to %u's no-mic-channel. You will see this channel as long as you are connected to this temp-channel.", - "disconnect-audit-log-reason": "The old channel of the user could not be found - disconnecting them - hopefully they join again", - "command-description": "Manage your temp-channel", - "mode-subcommand-description": "Change the mode of your channel", - "public-option-description": "If enabled, anyone can join your temp-channel", - "add-subcommand-description": "Add users, that will be able to join your channel, while it is private", - "remove-subcommand-description": "Remove users from you channel", - "add-user-option-description": "The user to be added", - "remove-user-option-description": "The user to be removed", - "list-subcommand-description": "List the users with access to your channel", - "edit-subcommand-description": "Edit various settings of your channel", - "user-limit-option-description": "Change the user-limit of your channel", - "bitrate-option-description": "Change the bitrate of your channel (min. 8000)", - "name-option-description": "Change the name of your channel", - "nsfw-option-description": "Change, whether your channel is age-restricted or not", - "no-added-user": "There are no users to be displayed here", - "nothing-changed": "Your channel already had these settings.", - "no-disconnect": "Couldn't disconnect the user from your channel. This could be due to missing permissions, or the user not being in your voice-channel", - "edit-error": "An error occurred while editing your channel. one or more of your settings couldn't be applied. This could be due to missing permissions or an invalid value.", - "add-user": "Add user", - "remove-user": "Remove user", - "list-users": "List users", - "private-channel": "Private", - "public-channel": "Public", - "edit-channel": "Edit channel", - "add-modal-title": "Add an user to your temp-channel", - "add-modal-prompt": "The user you want to add (tag or user-id)", - "remove-modal-title": "Remove an user from your temp-channel", - "remove-modal-prompt": "The user you want to remove (tag or user-id)", - "edit-modal-title": "Edit your temp-channel", - "edit-modal-nsfw-prompt": "Mark temp-channel as age-restricted?", - "edit-modal-nsfw-placeholder": "\"true\" (yes) or \"false\" (no)", - "edit-modal-nsfw-on": "Yes (age-restricted)", - "edit-modal-nsfw-off": "No (not age-restricted)", - "edit-modal-bitrate-prompt": "Bitrate of your Temp-channel?", - "edit-modal-bitrate-placeholder": "A number over 8000", - "edit-modal-limit-prompt": "Limit of users in your temp-channel", - "edit-modal-limit-placeholder": "Number between 0 and 99; 0 = unlimited", - "edit-modal-name-prompt": "How should your channel be called?", - "edit-modal-name-placeholder": "A very creative channel name", - "edit-modal-username-placeholder": "Username of the user", - "user-not-found": "User not found" + "config": { + "checking-config": "Checking configurations...", + "done-with-checking": "Done with checking. Out of %totalModules modules, %enabled were enabled, %configDisabled were disabled because their configuration was wrong.", + "creating-file": "Config %m/%f does not exist - I'm going to create it, please stand by...", + "checking-of-field-failed": "An error occurred while checking the content of field \"%fieldName\" in %m/%f", + "saved-file": "Configuration-File %f in %m was saved successfully.", + "moduleconf-regeneration": "Regenerating module configuration, no settings will be overwritten, don't worry.", + "moduleconf-regeneration-success": "Module configuration regeneration successfully finished.", + "channel-not-found": "Channel with ID \"%id\" could not be found", + "user-not-found": "User with ID \"%id\" could not be found", + "channel-not-on-guild": "Channel with ID \"%id\" is not on your server", + "channel-invalid-type": "Channel with ID \"%id\" has a type that can not be used for this field", + "role-not-found": "Role with ID \"%id\" could not be found on your server", + "config-reload": "Reloading all configuration..." }, - "guess-the-number": { - "command-description": "Manage your guess-the-number-games", - "status-command-description": "Shows the current status of a guess-the-number-game in this channel", - "create-command-description": "Create a new guess-the-number-game in this channel", - "create-min-description": "Minimal value users can guess", - "create-max-description": "Maximal value users can guess", - "create-number-description": "Number users should guess to win", - "end-command-description": "Ends the current game", - "session-already-running": "There is a session already running in this channel. Please end it with /guess-the-number end", - "session-not-running": "There is currently no session running.", - "gamechannel-modus": "You can't use this command in a gamechannel. To end a game, disable the gamechannel modus and try using the end command.", - "session-ended-successfully": "Ended session successfully. Locked channel successfully.", - "current-session": "Current session", - "number": "Number", - "min-val": "Min-Value", - "max-val": "Max-Value", - "owner": "Owner", - "guess-count": "Count of guesses", - "min-max-discrepancy": "`min` can't be bigger or equal to `max`", - "max-discrepancy": "`number` can't be bigger than `max`.", - "min-discrepancy": "`number` can't be smaller than `min`.", - "emoji-guide-button": "What does the reaction under my guess mean?", - "guide-wrong-guess": "Your guess was wrong (but your entry was valid)", - "guide-win": "You guessed correctly - you win :tada:", - "guide-admin-guess": "Your guess was invalid, because you are an admin - admins can't participate because they can see the correct number", - "guide-invalid-guess": "Your guess was invalid (e.g. below the minimal / over the maximal number, not a number, …)", - "created-successfully": "Created game successfully. Users can now start guessing in this channel. The winning number is **%n**. You can always check the status by running `/guess-the-number-status`. Note that you as an admin can not guess.", - "game-ended": "Game ended", - "game-started": "Game started", - "leaderboard-button": "Leaderboard", - "leaderboard-title": "Guess-the-Number Leaderboard", - "leaderboard-empty": "No games have been won yet.", - "wins": "wins", - "guesses": "guesses" + "connect-four": { + "tie": "It's a tie!", + "win": "%u has won the game!", + "not-turn": "Sorry, but it's not your turn!", + "game-message": "Connect Four game of %u1 and %u2\nCurrent turn: %c %t.\n\n%g", + "challenge-message": "%t, %u challenged you to a game of Connect Four! Hit the button below to join the game! This invitation will expire in about 2 minutes, so don't hesitate to much.", + "invite-expired": "Sorry, %u, %i didn't accept your request to play Connect Four in time ):", + "invite-denied": "Sorry, %u, but %i denied your request to play a round of Connect Four ):", + "command-description": "Play Connect Four against someone in the chat", + "field-size-description": "The size of the playfield (default: 7)", + "challenge-yourself": "You cannot challenge yourself!", + "challenge-bot": "You cannot challenge bots!" }, - "massrole": { - "command-description": "Manage roles for all members", - "add-subcommand-description": "Add a role to all members", - "remove-subcommand-description": "Remove a role from all members", - "remove-all-subcommand-description": "Remove all roles from all members", - "role-option-add-description": "The role, that will be given to all members", - "role-option-remove-description": "The role, that will be removed from all members", - "target-option-description": "Determines whether bots should be included or not", - "all-users": "All Users", - "bots": "Bots", - "humans": "Humans", - "not-admin": "⚠ To use this command, you need to be added to the adminRoles option in the SCNX-Dashboard. If you are the owner of this bot please remember to create an override in the server settings to prevent abuse of this command.", - "add-reason": "Mass role addition by %u", - "remove-reason": "Mass role removal by %u" + "counter": { + "created-db-entry": "Initialized database entry for %i", + "not-a-number": "This is not a number. You can not chat here. Try creating a thread if your message is that important.", + "restriction-audit-log": "This user proceeded to abuse the counter channel after five warnings, so we locked them out.", + "only-one-message-per-person": "Users have to take turns counting: You can not count two times in a row.", + "not-the-next-number": "That's not the next number. The next number is **%n**, please make sure you are counting up one by one.", + "channel-topic-change-reason": "Someone counted, so we updated the description as required by the configuration" }, - "twitch-notifications": { - "channel-not-found": "Channel with ID %c could not be found", - "user-not-on-twitch": "Could not find user %u on twitch", - "message-not-found": "No live message configured for streamer %s" + "duel": { + "command-description": "Play duel against someone in the chat", + "user-description": "User to play against", + "challenge-message": "%t, %u challenged you to a game of duel! Hit the button below to join the battle! This invitation will expire in about 2 minutes, so don't hesitate to much.", + "accept-invite": "Join game", + "deny-invite": "No thanks", + "self-invite-not-possible": "Are you really that lonely? Even Simon, a complete introvert with no friends and developer of this bot, can find another user to play duel with... You should be able to do that too, try inviting %r for example, maybe they want to play a round?", + "invite-expired": "Sorry, %u, %i didn't accept your request to play duel in time ):", + "invite-denied": "Sorry, %u, but %i denied your request to play a round of duel ):", + "you-are-not-the-invited-one": "Sorry, but this invite doesn't belong to you. You can start your own game with `/duel`.", + "game-running-header": "🎮 Game running", + "what-do-you-want-to-do": "**Select your action!**", + "pending": "⏳ Waiting for selection…", + "ready": "✅ Ready", + "continues-info": "The game continues once both parties have selected their next action.", + "how-does-this-game-work": "Wondering how this game works? Read our short explanation [here]().", + "use-gun": "Use gun", + "guard": "Guard", + "reload": "Load gun", + "game-ended": "🎮 Game ended", + "no-bullets": "Sorry, but you haven't loaded any bullets yet, so you can't use your gun yet.", + "bullets-full": "Sorry, but your gun only has place for 5 bullets at a time.", + "gun-gun": "Both %g1 and %g1 draw their guns. They stare each other and their eyes and slowly lower their weapons. No, the duell won't be resolved if both die - there can only be one winner.", + "guard-gun": "%g1 draws their gun and shoot - %d1 dodged the bullet successfully.", + "guard-guard": "Both %d1 and %d2 wait for each other to fire the shot - but nothing happens.", + "reload-gun": "While %r1 starts reloading their gun, %g1 draws their weapon and shoots - it's a head-shot. %r1 drops to the ground. %g1 should celebrate because they won, but they are left feeling bad for murdering their old friend.", + "guard-over-reload-gun": "As this is %r1's fifth guard in a row, they are tired and are to slow - %g1 shoots them directly into their head and %r1 drops to the ground. It's a win for %g1 - but at what price?", + "reload-reload": "Both %r1 and %r2 stare each other in the eyes while taking a short break to load one bullet each in their chamber.", + "reload-guard": "%d1 prepares to doge a bullet - but %r1 uses the time to load their weapon - no shots get fired.", + "ended-state": "This game ended. You can start a new duel with `/duel`.", + "not-your-game": "You are not one of players - you can start a new game with `/duel`." + }, + "economy-system": { + "work-earned-money": "The user %u gained %m %c by working", + "crime-earned-money": "The user %u gained %m %c by committing a crime", + "message-drop-earned-money": "The user %u gained %m %c by getting a message drop", + "rob-earned-money": "The user %u gained %m %c by robbing from %v", + "weekly-earned-money": "The user %u gained %m %c by cashing in their weekly reward", + "daily-earned-money": "The user %u gained %m %c by cashing in their daily reward", + "admin-self-abuse": "The admin %a wanted to abuse their permissions by giving them self even more money! This can't and should not be ignored!", + "admin-self-abuse-answer": "What a bad admin you are, %u. I'm disappointed with you! I need to report this. If I wish I could ban you!", + "added-money": "%i %c has been added to the balance of %u", + "removed-money": "%i %c has been removed from the balance of %u", + "set-money": "The balance of %u has been set to %i.", + "added-money-log": "The user %u added %i %c to the balance of %v", + "removed-money-log": "The user %u removed %i %c from the balance of %v", + "set-money-log": "The user %u set %v's balance to %i %c", + "command-description-main": "Use the economy-system", + "command-description-work": "Earn some cash by working", + "command-description-crime": "Earn some cash by committing a crime", + "command-description-rob": "Rob some cash from another user", + "option-description-rob-user": "User to rob from", + "crime-loose-money": "The user %u lost %m %c by committing a crime", + "command-description-daily": "Cash in your daily rewards", + "command-description-weekly": "Cash in your weekly rewards", + "command-description-balance": "Show the balance of a user", + "option-description-user": "User to execute action upon", + "command-description-add": "Add some cash to a user", + "command-description-remove": "Remove some cash from a user", + "option-description-amount": "Amount to manipulate", + "command-description-set": "Set a user's balance", + "option-description-balance": "Balance to set user to", + "message-drop": "Message-Drop: You earned %m %c simply by chatting!", + "created-item": "The user %u has created a new shop item: %i", + "item-duplicate": "The item already exist", + "role-to-high": "The specified role is higher than the highest role of the bot. Therefore the bot can't give the role to users. The item was **not** created.", + "delete-item": "The user %u has deleted the shop item %i", + "edit-item": "The user %u has edited the item %i. Possible changes are:\nNew name: %n\nNew price: %p\nNew role: %r", + "user-purchase": "The user %u has purchased the shop item %i for %p.", + "shop-command-description": "Use the shop-system", + "shop-command-description-add": "Create a new item in the shop (admins only)", + "shop-option-description-itemName": "Name of the item", + "shop-option-description-newItemName": "New name of the Item", + "shop-option-description-itemID": "ID of the Item", + "shop-option-description-price": "Price of the item", + "shop-option-description-role": "Role to give to users who buy the item", + "shop-command-description-buy": "Buy an item", + "shop-command-description-list": "List all items in the shop", + "shop-command-description-delete": "Remove an item from the shop", + "shop-command-description-edit": "Edit an item", + "channel-not-found": "Can't find the leaderboard channel with the ID %c", + "command-description-deposit": "Deposit xyz to your bank", + "option-description-amount-deposit": "Amount to deposit", + "command-description-withdraw": "Withdraw xyz from your Bank", + "option-description-amount-withdraw": "Amount to withdraw", + "command-group-description-msg-drop-msg": "Enable/ Disable the Message-Drop-Message", + "command-description-msg-drop-msg-enable": "Enable the Message-Drop-Message", + "command-description-msg-drop-msg-disable": "Disable the Message-Drop-Message", + "command-description-destroy": "Destroy the whole economy (deletes all Database-Entries)", + "option-description-confirm": "Confirm, that you really want to destroy the whole economy", + "destroy-cancel-reply": "You're lucky. You stopped me in the last moment before I destroyed the economy", + "destroy-reply": "Ok... I'll destroy the whole economy", + "destroy": "%u destroyed the economy", + "migration-happening": "Database not up-to-date. Migrating database...", + "migration-done": "Migrated database successfully.", + "nothing-selected": "Select an item to buy it", + "select-menu-price": "Price: %p", + "price-less-than-zero": "The price can't be less or equal to zero" }, "fun": { "slap-command-description": "Slap a user in the face", @@ -493,484 +365,465 @@ "dice-site-1": "Heads", "dice-site-2": "Tails" }, - "moderation": { - "moderate-command-description": "Moderate users on your server", - "moderate-notes-command-description": "Set or see moderator's notes of a user", - "moderate-notes-command-view": "View a user's notes", - "moderate-notes-command-create": "Create a new note about a user", - "moderate-notes-command-edit": "Edit one of your existing notes about a user", - "moderate-notes-command-delete": "Delete one of your existing notes about a user", - "moderate-ban-command-description": "Ban a user on your server", - "moderate-reason-description": "Reason for your action", - "moderate-proof-description": "Proof for your action", - "report-user-not-found-on-guild": "This user could not be found on \"%s\". You can only report users that are members of our server.", - "proof": "Proof", - "report-proof-description": "Attach an optional (image) proof to your report", - "file": "File uploaded", - "anti-grief-reason": "Too many actions of type \"%type\" in the last %h hours. Maximum amount allowed: %n", - "anti-grief-user-message": "Sorry, but it seems like you are abusing your moderative powers. We've taken actions to prevent this from happening.", - "moderate-duration-description": "Duration of the action (max: 28 days, default: 14 days)", - "mute-max-duration": "Discord limits the maximal duration of a timeout to 28 days. Please enter an amount equal or less than this", - "moderate-quarantine-command-description": "Quarantine a user on your server", - "moderate-unquarantine-command-description": "Removes a user from the quarantine", - "moderate-unban-command-description": "Revokes an existing ban", - "moderate-clear-command-description": "Clears messages in the current channel", - "moderate-clear-amount-description": "How many messages should get cleared?", - "moderate-kick-command-description": "Kick a user from your server", - "moderate-unwarn-command-description": "Revokes a warning", - "moderate-mute-command-description": "Mute a user on your server", - "moderate-unmute-command-description": "Unmutes a user on your server", - "moderate-warn-command-description": "Warn a user", - "moderate-channel-mute-description": "Mutes a user from the current channel", - "moderate-unchannel-mute-description": "Removes a channel-mute from this channel", - "moderate-lock-command-description": "Lock the current channel", - "moderate-unlock-command-description": "Unlock the current channel", - "moderate-lockdown-command-description": "Activate or lift server-wide lockdown", - "moderate-lockdown-enable-description": "True to activate lockdown, false to lift it", - "lockdown-not-enabled": "The lockdown system is not enabled. Enable it in the lockdown configuration.", - "lockdown-already-active": "A lockdown is already active.", - "lockdown-not-active": "No lockdown is currently active.", - "lockdown-activated": "Server Lockdown Activated", - "lockdown-lifted": "Server Lockdown Lifted", - "lockdown-activated-reply": "Lockdown activated. %c channels have been locked.", - "lockdown-lifted-reply": "Lockdown lifted. %c channels have been restored.", - "lockdown-log-description": "**Reason:** %r\n**Triggered by:** %u\n**Type:** %t\n**Affected channels:** %c", - "lockdown-lift-log-description": "**Reason:** %r\n**Lifted by:** %u\n**Restored channels:** %c", - "lockdown-automatic": "Automatic", - "lockdown-manual": "Manual", - "lockdown-system": "System", - "lockdown-auto-lift-reason": "Auto-lift timer expired", - "lockdown-restored": "Lockdown state restored from database after restart", - "lockdown-joinraid-trigger": "Join raid detected", - "lockdown-spam-trigger": "Excessive spam detected", - "lockdown-joingate-trigger": "Excessive join-gate violations detected", - "lockdown-restore-failed": "Failed to restore permissions for channel %c: %e", - "lockdown-users-kicked": "Users Kicked", - "lockdown-users-kicked-description": "%k non-moderator users were disconnected from voice channels.", - "moderate-user-description": "User on who the action should get performed", - "moderate-userid-description": "ID of a user", - "moderate-days-description": "Number of days of messages to delete", - "invalid-days": "Days can only be between 0 and 7 (inclusive)", - "moderate-notes-description": "Notes to set / update", - "moderate-note-id-description": "ID of one of your notes you want to edit (leave blank to create a new one)", - "moderate-warnid-description": "ID of a warn (run /moderate actions to get it)", - "moderate-actions-command-description": "Show all recorded actions against a user", - "report-command-description": "Reports a user and sends a snapshot of the chat to server staff", - "report-reason-description": "Please describe what the user did wrong", - "report-user-description": "User you want to report", - "no-reason": "Not set", - "muterole-not-found": "Could not find muterole. Can not perform this action", - "quarantinerole-not-found": "Could not find quarantinerole. Can not perform this action", - "mute-audit-log-reason": "Got muted by %u because of \"%r\"", - "unmute-audit-log-reason": "Got unmuted by %u because of \"%r\"", - "quarantine-audit-log-reason": "Got quarantined by %u because of \"%r\"", - "kicked-audit-log-reason": "Got kicked by %u because of \"%r\"", - "banned-audit-log-reason": "Got banned by %u because of \"%r\"", - "channelmute-audit-log-reason": "Got channel-mutet by %u because of \"%r\"", - "unchannelmute-audit-log-reason": "The Channel-Mute got removed by %u because of \"%r\"", - "unbanned-audit-log-reason": "Got unbanned by %u because of \"%r\"", - "unquarantine-audit-log-reason": "Got unquarantined by %u because of \"%r\"", - "action-expired": "Action expired", - "auto-mod": "Auto-Mod", - "batch-role-remove-failed": "Could not remove all roles from %i (trying to remove roles one by one): %e", - "batch-role-add-failed": "Could not add all roles to %i (trying to remove roles one by one): %e", - "could-not-remove-role": "Could not remove role %r from %i: %e", - "could-not-add-role": "Could not add role %r to %i: %e", - "reason": "Reason", - "join-gate": "Join-Gate", - "expires-at": "Action expires on", - "action": "Action", - "case": "Case", - "victim": "Victim", - "missing-logchannel": "LogChannel could not be found", - "reached-warns": "Reached %w warns", - "restored-punishment-audit-log-reason": "Restored punishment", - "anti-join-raid": "ANTI-JOIN-RAID", - "raid-detected": "Raid detected", - "joingate-for-everyone": "Join-Gate-Modus: Catch all users", - "account-age-to-low": "Account creation age of %a days is to low (required are more then %c)", - "no-profile-picture": "Account has no profile picture (required)", - "join-gate-fail": "Account failed Join-Gate (%r)", - "blacklisted-word": "Posted blacklisted word in %c", - "invite-sent": "Sent invite in %c", - "scam-url-sent": "Sent scam-url in %c", - "anti-spam": "Anti-Spam", - "reached-messages-in-timeframe": "Reached %m (normal) messages in less than %t seconds", - "reached-duplicated-content-messages": "Reached %m messages with the same content in less than %t", - "reached-ping-messages": "Reached %m messages with (user) pings in less then %t seconds", - "reached-massping-messages": "Reached %m messages with mass pings in less than %t seconds", - "action-done": "Executed action successfully. Action-ID: #%i", - "expiring-action-done": "Done. Action will expire on %d. Action-ID: #%i", - "cleared-channel": "Cleared channel successfully.\nNote: Messages older than 14 days can not be deleted using this method.", - "clear-failed": "An error occurred. You can only delete 100 messages at once.", - "no-quarantine-action-found": "Sorry, but I couldn't find any records of quarantining this users.", - "locked-channel-successfully": "Locked channel successfully. Only moderators (and admins) can write messages here now.", - "unlocked-channel-successfully": "Unlocked channel successfully. Permissions got restored to the permission-state before the lock occurred.", - "unlock-audit-log-reason": "User %u unlocked this channel by running /moderate unlock", - "warning-not-found": "I could not find this warning. Please make sure you are actually using a warning-id and not a userid.", - "can-not-report-mod": "You can not report moderators.", - "action-description-format": "%reason\nby %u on %t", - "no-actions-title": "None found", - "no-actions-value": "No actions against %u found.", - "actions-embed-title": "Mod-Actions against %u - Site %i", - "actions-embed-description": "You can find every action against %u here.", - "report-embed-title": "New report", - "report-embed-description": "A user reported another user. Please review the case and take actions if needed.", - "reported-user": "Reported user", - "report-reason": "Reason for the report", - "report-user": "User who submitted report", - "message-log": "Last 100 messages", - "message-log-description": "You can find an encrypted message-log [here](%u).", - "channel": "Channel", - "no-report-pings": "No pings configured. Check your configuration to ping your staff.", - "not-allowed-to-see-own-notes": "Sorry, but you are not allowed to see your own notes.", - "note-added": "Note added successfully", - "note-edited": "Edited note successfully", - "note-deleted": "Note deleted successfully", - "note-not-found-or-no-permissions": "Note not found or no permissions to edit this note.", - "notes-embed-title": "Notes about %u", - "info-field-title": "ℹ️ Information", - "no-notes-found": "No notes about this user. Create a new note with `/moderate notes create` and set the notes attribute.", - "more-notes": "%x other moderator also added notes about this user. Notes are sorted in reverse chronology, so you will see the newest notes first.", - "user-notes-field-title": "%t's notes", - "user-not-on-server": "I can't perform this action on this user, as they are not currently on your server.", - "verification": "VERIFICATION", - "verification-failed": "Verification failed", - "verification-started": "Verification got started", - "verification-completed": "Verification completed", - "user": "User", - "manual-verification-needed": "Manual verification needed", - "verification-deny": "Deny verification", - "verification-approve": "Approve verification", - "verification-skip": "Skip verification", - "captcha-verification-pending": "Captcha-Verification is pending. You can either wait for the user to complete it or skip it manually.", - "verification-update-proceeded": "Successfully update verification status", - "verify-channel-set-but-not-found-or-wrong-type": "The configured verify-channel could not be found or it's type is not supported.", - "generating-message": "We are preparing some stuff, this message should get edited shortly...", - "restart-verification-button": "Restart verification process", - "member-not-found": "This user could not be found, maybe they already left?", - "already-verified": "Seems like you are already verified... Why would you want to repeat this process?", - "restarted-verification": "I have sent you another DM about your verification process. Please read it carefully and follow the actions described in it. Please not that this action did not re-trigger the manual-verification (if enabled), so spamming this button is useless.", - "dms-still-disabled": "It seems like your DMs are still disabled. Please enable your DMs to start the verification. This is not optional, you need to do this in order to get access to %g.", - "dms-not-enabled-ping": "%p, it seems like you have your DMs disabled. Please enable them and hit the button below this message to verify yourself. You have two minutes to complete this process.", - "verify-me-button": "Verify Me", - "enter-solution-button": "Enter Solution", - "verification-submitted": "Your verification request has been submitted. A moderator will review it shortly.", - "already-pending-review": "Your verification request is already being reviewed by a moderator.", - "captcha-expired": "Your captcha has expired. Please click Verify Me again.", - "retry-message": "Wrong answer. You can try again in %t. (Attempt %a/%m)", - "cooldown-message": "⏳ Please wait %t% before trying again.", - "retries-exhausted": "You have exhausted all verification attempts.", - "simple-math-challenge": "What is %a %op %b?", - "simple-word-challenge": "Type the following word: %w", - "captcha-solution-label": "Enter the captcha solution", - "simple-solution-label": "Enter your answer", - "verification-modal-title": "Verification" - }, - "counter": { - "created-db-entry": "Initialized database entry for %i", - "not-a-number": "This is not a number. You can not chat here. Try creating a thread if your message is that important.", - "restriction-audit-log": "This user proceeded to abuse the counter channel after five warnings, so we locked them out.", - "only-one-message-per-person": "Users have to take turns counting: You can not count two times in a row.", - "not-the-next-number": "That's not the next number. The next number is **%n**, please make sure you are counting up one by one.", - "channel-topic-change-reason": "Someone counted, so we updated the description as required by the configuration" - }, - "tickets": { - "channel-not-found": "Ticket-Create-Channel could not be found", - "existing-ticket": "You already have a ticket open: %c", - "ticket-created-audit-log": "%u created a new ticket by clicking the button", - "ticket-created": "Successfully created ticket and notified staff. Head over to it: %c", - "no-admin-pings": "No pings configured. Check your configuration to ping your staff.", - "ticket-closed-successfully": "Closed ticket successfully. This channel will be deleted in a few seconds, thanks for reaching out to our support.", - "ticket-closed-audit-log": "%u closed ticket", - "closing-ticket": "Closing ticket as requested by %u...", - "ticket-with-user": "👤 Ticket-User", - "could-not-dm": "Could not DM %u: %r", - "no-log-channel": "Log-Channel not found", - "ticket-log-embed-title": "📎 Ticket %i closed", - "ticket-log": "Ticket-Log", - "ticket-type": "☕ Ticket-Topic", - "ticket-log-value": "Transcript with %n messages can be found [here](%u).", - "closed-by": "👷 Ticket closed by" - }, - "reminders": { - "command-description": "Set a reminder for yourself", - "in-description": "After what time should we remind you? (eg. \"2h 30m\")", - "what-description": "What should we remind you about?", - "dm-description": "Should we send you a DM instead of reminding your in this channel?", - "one-minute-in-future": "Your reminder needs to be at least one minute in the future", - "reminder-set": "Reminder set. We'll remind you at %d.", - "snooze-10m": "10 min", - "snooze-30m": "30 min", - "snooze-1h": "1 hour", - "snooze-1d": "1 day", - "snoozed": "Reminder snoozed. We'll remind you again at %d.", - "snooze-not-allowed": "You can only snooze your own reminders." - }, - "afk-system": { - "command-description": "Manage your AFK-Status on this server", - "end-command-description": "End your current AFK-Session", - "start-command-description": "Start a new AFK-Session", - "reason-option-description": "Explain why you started this session", - "autoend-option-description": "If enabled, the bot will auto-end your AFK Session when your write a message (default: enabled)", - "no-running-session": "You don't have any session running.", - "already-running-session": "You already have an AFK-Session running, try ending it with `/afk-system end`.", - "afk-nickname-change-audit-log": "Updated user nickname because they started an AFK-Session", - "can-not-edit-nickname": "Can not edit nickname of %u: %e" - }, - "tic-tac-toe": { - "command-description": "Play tic-tac-toe against someone in the chat", - "user-description": "User to play against", - "challenge-message": "%t, %u challenged you to a game of tic-tac-toe! Hit the button below to join the battle! This invitation will expire in about 2 minutes, so don't hesitate to much.", - "accept-invite": "Join game", - "deny-invite": "No thanks", - "self-invite-not-possible": "Are you really that lonely? Even Simon, a complete introvert with no friends and developer of this bot, can find another user to play tic-tac-toe with... You should be able to do that too, try inviting %r for example, maybe they want to play a round?", - "invite-expired": "Sorry, %u, %i didn't accept your request to play tic-tac-toe in time ):", - "invite-denied": "Sorry, %u, but %i denied your request to play a round of tic-tac-toe ):", - "you-are-not-the-invited-one": "Sorry, but this invite doesn't belong to you. You can start your own game with `/tic-tac-toe`.", - "playing-header": "**TIC-TAC-TOE GAME IS RUNNING**\n\n%u (🟢) VS %i (🟡)\nCurrently on turn: %t\n\n%t, click a button with a white circle below to place your marker", - "win-header": "**TIC-TOE-GAME ENDED**\n\n%u (🟢) VS %i (🟡)\n\n%w won the game - GG!\n\n*You can start a new round by using `/tic-tac-toe`*", - "draw-header": "**TIC-TOE-GAME ENDED**\n\n%u (🟢) VS %i (🟡)\n\nDraw - no one won this game.", - "not-your-turn": "It's not your turn, take a coffee and return later" - }, - "duel": { - "command-description": "Play duel against someone in the chat", - "user-description": "User to play against", - "challenge-message": "%t, %u challenged you to a game of duel! Hit the button below to join the battle! This invitation will expire in about 2 minutes, so don't hesitate to much.", - "accept-invite": "Join game", - "deny-invite": "No thanks", - "self-invite-not-possible": "Are you really that lonely? Even Simon, a complete introvert with no friends and developer of this bot, can find another user to play duel with... You should be able to do that too, try inviting %r for example, maybe they want to play a round?", - "invite-expired": "Sorry, %u, %i didn't accept your request to play duel in time ):", - "invite-denied": "Sorry, %u, but %i denied your request to play a round of duel ):", - "you-are-not-the-invited-one": "Sorry, but this invite doesn't belong to you. You can start your own game with `/duel`.", - "game-running-header": "🎮 Game running", - "what-do-you-want-to-do": "**Select your action!**", - "pending": "⏳ Waiting for selection…", - "ready": "✅ Ready", - "continues-info": "The game continues once both parties have selected their next action.", - "how-does-this-game-work": "Wondering how this game works? Read our short explanation [here]().", - "use-gun": "Use gun", - "guard": "Guard", - "reload": "Load gun", - "game-ended": "🎮 Game ended", - "no-bullets": "Sorry, but you haven't loaded any bullets yet, so you can't use your gun yet.", - "bullets-full": "Sorry, but your gun only has place for 5 bullets at a time.", - "gun-gun": "Both %g1 and %g1 draw their guns. They stare each other and their eyes and slowly lower their weapons. No, the duell won't be resolved if both die - there can only be one winner.", - "guard-gun": "%g1 draws their gun and shoot - %d1 dodged the bullet successfully.", - "guard-guard": "Both %d1 and %d2 wait for each other to fire the shot - but nothing happens.", - "reload-gun": "While %r1 starts reloading their gun, %g1 draws their weapon and shoots - it's a head-shot. %r1 drops to the ground. %g1 should celebrate because they won, but they are left feeling bad for murdering their old friend.", - "guard-over-reload-gun": "As this is %r1's fifth guard in a row, they are tired and are to slow - %g1 shoots them directly into their head and %r1 drops to the ground. It's a win for %g1 - but at what price?", - "reload-reload": "Both %r1 and %r2 stare each other in the eyes while taking a short break to load one bullet each in their chamber.", - "reload-guard": "%d1 prepares to doge a bullet - but %r1 uses the time to load their weapon - no shots get fired.", - "ended-state": "This game ended. You can start a new duel with `/duel`.", - "not-your-game": "You are not one of players - you can start a new game with `/duel`." - }, - "economy-system": { - "work-earned-money": "The user %u gained %m %c by working", - "crime-earned-money": "The user %u gained %m %c by committing a crime", - "message-drop-earned-money": "The user %u gained %m %c by getting a message drop", - "rob-earned-money": "The user %u gained %m %c by robbing from %v", - "weekly-earned-money": "The user %u gained %m %c by cashing in their weekly reward", - "daily-earned-money": "The user %u gained %m %c by cashing in their daily reward", - "admin-self-abuse": "The admin %a wanted to abuse their permissions by giving them self even more money! This can't and should not be ignored!", - "admin-self-abuse-answer": "What a bad admin you are, %u. I'm disappointed with you! I need to report this. If I wish I could ban you!", - "added-money": "%i %c has been added to the balance of %u", - "removed-money": "%i %c has been removed from the balance of %u", - "set-money": "The balance of %u has been set to %i.", - "added-money-log": "The user %u added %i %c to the balance of %v", - "removed-money-log": "The user %u removed %i %c from the balance of %v", - "set-money-log": "The user %u set %v's balance to %i %c", - "command-description-main": "Use the economy-system", - "command-description-work": "Earn some cash by working", - "command-description-crime": "Earn some cash by committing a crime", - "command-description-rob": "Rob some cash from another user", - "option-description-rob-user": "User to rob from", - "crime-loose-money": "The user %u lost %m %c by committing a crime", - "command-description-daily": "Cash in your daily rewards", - "command-description-weekly": "Cash in your weekly rewards", - "command-description-balance": "Show the balance of a user", - "option-description-user": "User to execute action upon", - "command-description-add": "Add some cash to a user", - "command-description-remove": "Remove some cash from a user", - "option-description-amount": "Amount to manipulate", - "command-description-set": "Set a user's balance", - "option-description-balance": "Balance to set user to", - "message-drop": "Message-Drop: You earned %m %c simply by chatting!", - "created-item": "The user %u has created a new shop item: %i", - "item-duplicate": "The item already exist", - "role-to-high": "The specified role is higher than the highest role of the bot. Therefore the bot can't give the role to users. The item was **not** created.", - "delete-item": "The user %u has deleted the shop item %i", - "edit-item": "The user %u has edited the item %i. Possible changes are:\nNew name: %n\nNew price: %p\nNew role: %r", - "user-purchase": "The user %u has purchased the shop item %i for %p.", - "shop-command-description": "Use the shop-system", - "shop-command-description-add": "Create a new item in the shop (admins only)", - "shop-option-description-itemName": "Name of the item", - "shop-option-description-newItemName": "New name of the Item", - "shop-option-description-itemID": "ID of the Item", - "shop-option-description-price": "Price of the item", - "shop-option-description-role": "Role to give to users who buy the item", - "shop-command-description-buy": "Buy an item", - "shop-command-description-list": "List all items in the shop", - "shop-command-description-delete": "Remove an item from the shop", - "shop-command-description-edit": "Edit an item", - "channel-not-found": "Can't find the leaderboard channel with the ID %c", - "command-description-deposit": "Deposit xyz to your bank", - "option-description-amount-deposit": "Amount to deposit", - "command-description-withdraw": "Withdraw xyz from your Bank", - "option-description-amount-withdraw": "Amount to withdraw", - "command-group-description-msg-drop-msg": "Enable/ Disable the Message-Drop-Message", - "command-description-msg-drop-msg-enable": "Enable the Message-Drop-Message", - "command-description-msg-drop-msg-disable": "Disable the Message-Drop-Message", - "command-description-destroy": "Destroy the whole economy (deletes all Database-Entries)", - "option-description-confirm": "Confirm, that you really want to destroy the whole economy", - "destroy-cancel-reply": "You're lucky. You stopped me in the last moment before I destroyed the economy", - "destroy-reply": "Ok... I'll destroy the whole economy", - "destroy": "%u destroyed the economy", - "migration-happening": "Database not up-to-date. Migrating database...", - "migration-done": "Migrated database successfully.", - "nothing-selected": "Select an item to buy it", - "select-menu-price": "Price: %p", - "price-less-than-zero": "The price can't be less or equal to zero" + "guess-the-number": { + "command-description": "Manage your guess-the-number-games", + "status-command-description": "Shows the current status of a guess-the-number-game in this channel", + "create-command-description": "Create a new guess-the-number-game in this channel", + "create-min-description": "Minimal value users can guess", + "create-max-description": "Maximal value users can guess", + "create-number-description": "Number users should guess to win", + "end-command-description": "Ends the current game", + "session-already-running": "There is a session already running in this channel. Please end it with /guess-the-number end", + "session-not-running": "There is currently no session running.", + "gamechannel-modus": "You can't use this command in a gamechannel. To end a game, disable the gamechannel modus and try using the end command.", + "session-ended-successfully": "Ended session successfully. Locked channel successfully.", + "current-session": "Current session", + "number": "Number", + "min-val": "Min-Value", + "max-val": "Max-Value", + "owner": "Owner", + "guess-count": "Count of guesses", + "min-max-discrepancy": "`min` can't be bigger or equal to `max`", + "max-discrepancy": "`number` can't be bigger than `max`.", + "min-discrepancy": "`number` can't be smaller than `min`.", + "emoji-guide-button": "What does the reaction under my guess mean?", + "guide-wrong-guess": "Your guess was wrong (but your entry was valid)", + "guide-win": "You guessed correctly - you win :tada:", + "guide-admin-guess": "Your guess was invalid, because you are an admin - admins can't participate because they can see the correct number", + "guide-invalid-guess": "Your guess was invalid (e.g. below the minimal / over the maximal number, not a number, …)", + "created-successfully": "Created game successfully. Users can now start guessing in this channel. The winning number is **%n**. You can always check the status by running `/guess-the-number-status`. Note that you as an admin can not guess.", + "game-ended": "Game ended", + "game-started": "Game started", + "leaderboard-button": "Leaderboard", + "leaderboard-title": "Guess-the-Number Leaderboard", + "leaderboard-empty": "No games have been won yet.", + "wins": "wins", + "guesses": "guesses" }, - "status-role": { - "fulfilled": "Status-role condition is fulfilled", - "not-fulfilled": "Status-role condition is no longer fulfilled" + "guildVerification": { + "0": "None", + "1": "Low", + "2": "Medium", + "3": "High", + "4": "Very high" }, - "color-me": { - "create-log-reason": "%user redeemed their boosting-rewards by requesting the creation of this role", - "edit-log-reason": "%user edited their boosting-reward-role", - "delete-unboost-log-reason": "%user stopped boosting, so their role got deleted", - "delete-manual-log-reason": "%user deleted their role manually", - "command-description": "Request a Custom role as a reward for boosting. This has a cooldown of 24 hours", - "manage-subcommand-description": "Create or edit your custom role", - "name-option-description": "The name of your custom role", - "color-option-description": "The color of your custom role", - "remove-subcommand-description": "Remove your custom role", - "icon-option-description": "Your role-icon", - "confirm-option-remove-description": "Do you really want to delete your custom role? This will not reset any running cooldowns" + "help": { + "bot-info-titel": "ℹ️ Bot-Info", + "bot-info-description": "This bot is part of [SCNX](https://scnx.xyz/de?ref=custombot_help_embed), a plattform from [ScootKit](https://scootkit.net) allowing the creation of fully customizable for Discord communities, and is being hosted for \"%g\".", + "stats-title": "📊 Stats", + "stats-content": "Active modules: %am\nRegistered commands: %rc\nBot-Version: %v\nRunning on server %si\n[Server-Plan](https://scnx.xyz/plan): %pl\nLast restart: %lr\nLast reload: %lR", + "command-description": "Show every commands", + "slash-commands-title": "Slash-Commands", + "select-module-placeholder": "Select a module to view its commands", + "select-module-hint": "👇 Use the dropdown below to browse commands by module.", + "back-to-overview": "Back to overview", + "modules-overview": "📋 Modules & Commands", + "built-in-description": "Core commands built into the bot", + "custom-commands-label": "Custom Commands", + "custom-commands-description": "User-created custom slash commands" }, - "rock-paper-scissors": { - "stone": "Stone", - "paper": "Paper", - "scissors": "Scissors", - "won": "won", - "lost": "lost", - "tie": "tie", - "play-again": "Play again", - "challenge-message": "%t, %u challenged you to a game of rock-paper-scissors! Hit the button below to join the game! This invitation will expire in about 2 minutes, so don't hesitate to much.", - "invite-expired": "Sorry, %u, %i didn't accept your request to play rock-paper-scissors in time ):", - "invite-denied": "Sorry, %u, but %i denied your request to play a round of rock-paper-scissors ):", - "rps-title": "Rock Paper Scissors", - "rps-description": "Choose your weapon!", - "its-a-tie-try-again": "It's a tie! Try again!", - "command-description": "Play rock-paper-scissors against the bot or someone in the chat" + "helpers": { + "timestamp": "%dd.%mm.%yyyy at %hh:%min", + "you-did-not-run-this-command": "You did not run this command. If you want to use the buttons, try running the command yourself.", + "next": "Next", + "back": "Back", + "toggle-data-fetch-error": "SC Network Release: Toggle-Data could not be fetched", + "toggle-data-fetch": "SC Network Release: Toggle-Data fetched successfully", + "duration-just-now": "just now", + "duration-minute": "%i minute", + "duration-minutes": "%i minutes", + "duration-hour": "%i hour", + "duration-hours": "%i hours", + "duration-day": "%i day", + "duration-days": "%i days", + "duration-month": "%i month", + "duration-months": "%i months", + "duration-year": "%i year", + "duration-years": "%i years", + "voice-time-hm": "%hh %mm", + "voice-time-m": "%im", + "voice-time-s": "%is" }, - "connect-four": { - "tie": "It's a tie!", - "win": "%u has won the game!", - "not-turn": "Sorry, but it's not your turn!", - "game-message": "Connect Four game of %u1 and %u2\nCurrent turn: %c %t.\n\n%g", - "challenge-message": "%t, %u challenged you to a game of Connect Four! Hit the button below to join the game! This invitation will expire in about 2 minutes, so don't hesitate to much.", - "invite-expired": "Sorry, %u, %i didn't accept your request to play Connect Four in time ):", - "invite-denied": "Sorry, %u, but %i denied your request to play a round of Connect Four ):", - "command-description": "Play Connect Four against someone in the chat", - "field-size-description": "The size of the playfield (default: 7)", - "challenge-yourself": "You cannot challenge yourself!", - "challenge-bot": "You cannot challenge bots!" + "info-commands": { + "info-command-description": "Find information about parts of this server", + "command-userinfo-description": "Find more information about a user on this server", + "argument-userinfo-user-description": "User you want to see information about (default: you)", + "command-roleinfo-description": "Find more information about a role on this server", + "argument-roleinfo-role-description": "Role you want to see information about", + "command-channelinfo-description": "Find more information about a channel on this server", + "argument-channelinfo-channel-description": "Channel you want to see information about", + "command-serverinfo-description": "Find more information about this server", + "information-about-role": "Information about the role %r", + "hoisted": "Hoisted", + "mentionable": "Mentionable", + "managed": "Managed", + "information-about-channel": "Information about the channel %c", + "information-about-user": "Information about the user %u", + "information-about-server": "Information about %s", + "boostLevel": "Level", + "boostCount": "Boosts", + "userCount": "Users", + "memberCount": "Members", + "onlineCount": "Online", + "textChannel": "Text", + "voiceChannel": "Voice", + "categoryChannel": "Categories", + "otherChannel": "Other", + "total-invites": "Total", + "active-invites": "Active", + "left-invites": "Left" }, - "uno": { - "command-description": "Play Uno against users in the chat", - "challenge-message": "%u invites to a round of Uno! Click the button below this message to join! The game starts %timestamp with %count players.", - "not-enough-players": "Not enough players joined for a round of Uno!", - "user-cards": "%u: %cards cards", - "already-joined": "You're already in!", - "view-deck": "View deck", - "draw": "Draw card", - "uno": "Uno!", - "turn": "It's %u turn!", - "update-button": "Update", - "use-drawn": "Do you want to use the drawn card?", - "dont-use-drawn": "Dont use", - "win": "%u won the game! %turns cards were played.", - "win-you": "You've won the game!", - "missing-uno": "⚠️ You must use the Uno! button before you use your second last card!", - "choose-color": "Select a color:", - "pending-draws": "Use a Draw 2/4 card, otherwise you have to draw %count cards!", - "not-ingame": "You're not in this game!", - "skip": "Skip", - "reverse": "Reverse", - "color": "Color choice", - "draw2": "Draw 2", - "colordraw4": "Color choice and draw 4", - "cant-uno": "You cannot use Uno currently.", - "done-uno": "You've called Uno!", - "auto-drawn-skip": "Your turn was skipped because you would have had to draw the cards anyway.", - "start-game": "Start game now", - "not-host": "You're not the host of the game!", - "max-players": "The game is full!", - "previous-cards": "Previous cards: ", - "used-card": "You've already used the card %c! Use the Update button and play a valid card.", - "invalid-card": "You cannot play the card %c right now! Please select a valid card.", - "inactive-warn": "%u, it's your turn in the uno game!", - "inactive-win": "The uno game has ended. %u won as all others have been eliminated!" + "levels": { + "leaderboard-channel-not-found": "Leaderboard-Channel not found or wrong type", + "leaderboard-notation": "%p. %u: Level %l - %xp XP", + "list-location": "[Level System] The live leaderboard is currently located here: %l. Delete the message and restart the bot, to re-send it.", + "leaderboard": "Leaderboard", + "no-user-on-leaderboard": "Can't generate a leaderboard, because no one has any XP which is odd, but that's how it is ¯\\_(ツ)_/¯", + "and-x-other-users": "and %uc other users", + "level": "Level %l", + "users": "Users", + "leaderboard-command-description": "Shows the leaderboard of this server", + "leaderboard-sortby-description": "How to sort the leaderboard (default: %d)", + "profile-command-description": "Shows the profile of you or an an user", + "profile-user-description": "User to see the profile from (default: you)", + "please-send-a-message": "Please send some messages before I can show you some data", + "no-role": "None", + "are-you-sure-you-want-to-delete-user-xp": "Okay, do you really want to screw with %u? If you hate them so much, feel free to run `/manage-levels reset-xp confirm:True user:%ut` to run this irreversible action.", + "are-you-sure-you-want-to-delete-server-xp": "Do you really want to delete all XP and Levels from this server? This action is irreversible and everyone on this server will hate you. Decided that it's worth it? Enter `/manage-levels reset-xp confirm:True`", + "user-not-found": "User not found", + "user-deleted-users-xp": "%t deleted the XP of the user with id %u", + "removed-xp-successfully": "`Removed %u's XP and level successfully.`", + "deleted-server-xp": "%u deleted the XP of all users", + "successfully-deleted-all-xp-of-users": "Successfully deleted all the XP of all users", + "cheat-no-profile": "This user doesn't have a profile (yet), please force them to write a message before trying to betrayal your community by manipulating level scores.", + "manipulated": "%u manipulated the XP of %m to %v (level %l)", + "successfully-changed": "Successfully edited the XP of %u - they are now **level %l** with **%x XP**.\nRemember, every change you make destroys the experience of other users on this server as the levelsystem isn't fair anymore.", + "edit-xp-command-description": "Manage the levels of your server", + "negative-xp": "This user would have a negative XP value which is not possible", + "negative-level": "This user would have a level below one which is not possible", + "xp-out-of-range": "This XP value is too large. Please choose a value below 1,000,000,000,000.", + "level-out-of-range": "This level value is too large. Please choose a value below 1,000,000.", + "reset-xp-description": "Reset the XP of a user or of the whole server", + "reset-xp-user-description": "User to reset the XP from (default: whole server)", + "reset-xp-confirm-description": "Do you really want to delete the data?", + "edit-xp-user-description": "User to edit", + "edit-xp-value-description": "New XP value of the user", + "edit-xp-description": "Betrays your community and edits a user's XP", + "no-custom-formula": "No valid custom formula was entered. Using default formula.", + "invalid-custom-formula": "Invalid custom formula was entered. Please either fix the syntax of your custom formula or remove the value of the custom formula field.", + "role-factors-total": "Multiplied together, the user receives **%f times more XP** for every message.", + "edit-level-description": "Betrays your community and edits a user's levels", + "random-messages-enabled-but-non-configured": "You have random messages enabled, but have non configured. Ignoring config.randomMessages configuration.", + "granted-rewards-audit-log": "Updated roles to make sure, they have the level role they need", + "xp-type-message": "Message", + "xp-type-voice": "Voice", + "calculate-level-command-description": "Calculate the XP and messages required to reach a specific level", + "calculate-level-level-description": "The level you want to calculate the requirements for", + "calculate-level-embed-title": "Level %l - Requirements", + "calculate-level-formula": "Level formula", + "calculate-level-xp-needed": "XP required for level %l", + "calculate-level-messages-needed": "Messages required for level %l", + "calculate-level-messages-value": "Min: %min, Average: %avg, Maximum: %max", + "calculate-level-zero-xp-range": "Cannot calculate messages required: the configured XP-per-message range includes zero. Adjust the `min-xp` and `max-xp` settings.", + "calculate-level-above-max": "Level %requested is above the configured maximum level (%max).", + "calculate-level-voice-needed": "Voice minutes required for level %l", + "calculate-level-voice-value": "%minutes minute(s)" }, - "quiz": { - "what-have-i-voted": "What have I voted?", - "vote": "Vote!", - "vote-this": "Select this option if you think it's correct.", - "voted-successfully": "Selected successfully.", - "not-voted-yet": "You have not selected an option yet, so I can't show you what you selected.", - "you-voted": "You've selected **%o** as correct answer.", - "change-opinion": "You can change your opinion at any time by selecting another option above the button you just clicked.", - "cannot-change-opinion": "You cannot change your selection as the creator of this quiz disabled it.", - "select-correct": "Select all correct answers", - "this-correct": "Mark this answer as correct", - "cmd-description": "Create or play server quiz", - "cmd-create-normal-description": "Create a quiz with up to 10 answers", - "cmd-create-bool-description": "Create a quiz with true or false answers", - "cmd-play-description": "Play a server quiz", - "cmd-leaderboard-description": "Shows the quiz leaderboard of the server", - "cmd-create-description-description": "Title / description of the quiz", - "cmd-create-channel-description": "Channel in which the quiz should be created", - "cmd-create-endAt-description": "How long the quiz will last", - "cmd-create-option-description": "Option number %o", - "cmd-create-canchange-description": "If the players can change their opinion after voting (default: no)", - "daily-quiz-limit": "You've reached the limit of **%l** daily playable quizzes. You can play again %timestamp.", - "created": "Quiz created successfully in %c.", - "correct-highlighted": "All correct answers were highlighted.", - "answer-correct": "✅ Your answer was correct and you've received one point for the leaderboard!", - "answer-wrong": "❌ Your answer was wrong!", - "bool-true": "Statement is correct", - "bool-false": "Statement is wrong", - "leaderboard-channel-not-found": "The leaderboard channel couldn't be found or it's type is invalid.", - "leaderboard-notation": "**%p. %u**: %xp XP", - "your-rank": "You've collected **%xp** points in quiz!", - "no-rank": "You've never finished a quiz successfully!", - "no-quiz": "No quizzes have been created for this server. Trusted admins can create them on https://scnx.app/glink?page=bot/configuration?query=quiz&file=quiz%7Cconfigs%2FquizList .", - "no-permission": "You don't have enough permissions to create quiz using the command." + "main": { + "startup-info": "SCNX-CustomBot v2 - Log-Level: %l", + "missing-moduleconf": "Missing moduleConfig-file. Automatically disabling all modules and overwriting modules.json later", + "sync-db": "Synced database", + "login-error": "Bot could not log in. Error: %e", + "login-error-token": "Bot could not log in because the provided token is invalid. Please update your token.", + "login-error-intents": "Bot could not log in because the intents were not enabled correctly. Please enable \"PRESENCE INTENT\", \"SERVER MEMBERS INTENT\" and \"MESSAGE CONTENT INTENT\" in your Discord-Developer-Dashboard: %url", + "not-invited": "Please invite the bot to your Discord server before continuing: %inv", + "require-code-grant-active": "You might be unable to invite your bot to your server as you have enabled the \"Require public code grant\" option in your Discord Developer Dashboard. Please disable this option: %d", + "interactions-endpoint-active": "You bot will be unable to respond to interactions, because the field \"Interactions Endpoint URL\" has a value in your Discord Developer Dashboard. Please remove any content from this field and restart your bot: %d", + "logged-in": "Bot logged in as %tag and is now online.", + "logchannel-wrong-type": "There is no Log-Channel set or it has the wrong type (only text-channels are supported).", + "config-check-failed": "Configuration-Check failed. You can find more information in your log. The bot exited.", + "bot-ready": "The bot initiated successfully and is now listening to commands", + "no-command-permissions": "Could not update server commands. Please give us permissions to performe this critical action: %inv", + "perm-sync": "Synced permissions for /%c", + "perm-sync-failed": "Failed to synced permissions for /%c: %e", + "loading-module": "Loading module %m", + "hidden-module": "Module %m is hidden, meaning that it is not available. Skipping…", + "module-disabled": "Module %m is disabled", + "command-loaded": "Loaded command %d/%f", + "command-dir": "Loading commands in %d/%f", + "global-command-sync": "Synced global application commands", + "guild-command-sync": "Synced server application commands", + "guild-command-no-sync-required": "Server application commands are up to date - no syncing required", + "global-command-no-sync-required": "Global application commands are up to date - no syncing required", + "event-loaded": "Loaded events %d/%f", + "event-dir": "Loading events in %d/%f", + "model-loaded": "Loaded database model %d/%f", + "model-dir": "Loading database model in %d/%f", + "loaded-cli": "Loaded API-Action %c in %p", + "channel-lock": "Locked channel", + "channel-unlock": "Unlocked channel", + "channel-unlock-data-not-found": "Unlocking channel with ID %c failed because it was never locked (which is weird to begin with).", + "module-disable": "Module %m got disabled because %r", + "migrate-success": "Migration from %o to %m finished successfully.", + "migrate-start": "Migration from %o to %m started... Please do not stop the bot", + "shutdown-deferred": "Shutdown requested but a database migration is in progress. Will shut down after migration completes.", + "shutdown-after-migration": "Migration complete, proceeding with shutdown.", + "uncaught-exception": "Uncaught exception: %e - continuing execution.", + "unhandled-rejection": "Unhandled promise rejection: %e - continuing execution.", + "discord-error": "Discord.js error: %e", + "shard-error": "Discord shard error: %e", + "shard-disconnect": "Disconnected from Discord (close event code: %c). Reconnection will be attempted automatically.", + "shard-reconnecting": "Reconnecting to Discord…", + "db-connect-error": "Could not connect to the database: %e - the bot will now exit.", + "cli-command-error": "CLI command error: %e", + "discord-api-error": "Could not reach the Discord API during startup: %e - some checks were skipped.", + "home-guild-kicked": "Bot was removed from the configured home guild (%g). Pausing operations and waiting to be re-added.", + "home-guild-rejoined": "Bot was re-added to the home guild. Reloading configuration.", + "home-guild-unavailable": "Home guild (%g) is currently unavailable (likely a Discord outage). Pausing operations until it returns.", + "home-guild-available": "Home guild (%g) is available again. Resuming operations." }, - "topgg": { - "channel-not-found": "The configured channel with the ID \"%c\" was not found", - "testvote-header": "This was a test vote", - "voterole-reached": "Voted on top.gg and received Voter-Role", - "voterole-ended": "Vote on top.gg expired and got Voter-Role removed", - "opt-in": "Enable notifications when you can vote again", - "opt-out": "Disable notifications when you can vote again", - "opted-in": "Successfully opted in into receiving notifications when you can vote again", - "opted-out": "Successfully opted out of receiving notifications when you can vote again", - "already-opted-in": "You are already opted-in and will receive notifications when you can vote again", - "already-opted-out": "You are already opted-out and will **not** receive notifications when you can vote again", - "voteamount-reached": "The user reached %k votes which resulted in this role to be given.", - "testvote-description": "This vote was triggered in the top.gg dashboard and does not count towards any votecount of anyone and won't be used for reminders." + "massrole": { + "command-description": "Manage roles for all members", + "add-subcommand-description": "Add a role to all members", + "remove-subcommand-description": "Remove a role from all members", + "remove-all-subcommand-description": "Remove all roles from all members", + "role-option-add-description": "The role, that will be given to all members", + "role-option-remove-description": "The role, that will be removed from all members", + "target-option-description": "Determines whether bots should be included or not", + "all-users": "All Users", + "bots": "Bots", + "humans": "Humans", + "not-admin": "⚠ To use this command, you need to be added to the adminRoles option in the SCNX-Dashboard. If you are the owner of this bot please remember to create an override in the server settings to prevent abuse of this command.", + "add-reason": "Mass role addition by %u", + "remove-reason": "Mass role removal by %u" }, - "starboard": { - "invalid-minstars": "Invalid minimum stars %stars", - "star-limit": "You've reached the hourly starboard limit of %limitEmoji on the server which is why you cannot react on the message %msgUrl .\nTry again %time!" + "moderation": { + "moderate-command-description": "Moderate users on your server", + "moderate-notes-command-description": "Set or see moderator's notes of a user", + "moderate-notes-command-view": "View a user's notes", + "moderate-notes-command-create": "Create a new note about a user", + "moderate-notes-command-edit": "Edit one of your existing notes about a user", + "moderate-notes-command-delete": "Delete one of your existing notes about a user", + "moderate-ban-command-description": "Ban a user on your server", + "moderate-reason-description": "Reason for your action", + "moderate-proof-description": "Proof for your action", + "report-user-not-found-on-guild": "This user could not be found on \"%s\". You can only report users that are members of our server.", + "proof": "Proof", + "report-proof-description": "Attach an optional (image) proof to your report", + "file": "File uploaded", + "anti-grief-reason": "Too many actions of type \"%type\" in the last %h hours. Maximum amount allowed: %n", + "anti-grief-user-message": "Sorry, but it seems like you are abusing your moderative powers. We've taken actions to prevent this from happening.", + "moderate-duration-description": "Duration of the action (max: 28 days, default: 14 days)", + "mute-max-duration": "Discord limits the maximal duration of a timeout to 28 days. Please enter an amount equal or less than this", + "moderate-quarantine-command-description": "Quarantine a user on your server", + "moderate-unquarantine-command-description": "Removes a user from the quarantine", + "moderate-unban-command-description": "Revokes an existing ban", + "moderate-clear-command-description": "Clears messages in the current channel", + "moderate-clear-amount-description": "How many messages should get cleared?", + "moderate-kick-command-description": "Kick a user from your server", + "moderate-unwarn-command-description": "Revokes a warning", + "moderate-mute-command-description": "Mute a user on your server", + "moderate-unmute-command-description": "Unmutes a user on your server", + "moderate-warn-command-description": "Warn a user", + "moderate-channel-mute-description": "Mutes a user from the current channel", + "moderate-unchannel-mute-description": "Removes a channel-mute from this channel", + "moderate-lock-command-description": "Lock the current channel", + "moderate-unlock-command-description": "Unlock the current channel", + "moderate-lockdown-command-description": "Activate or lift server-wide lockdown", + "moderate-lockdown-enable-description": "True to activate lockdown, false to lift it", + "lockdown-not-enabled": "The lockdown system is not enabled. Enable it in the lockdown configuration.", + "lockdown-already-active": "A lockdown is already active.", + "lockdown-not-active": "No lockdown is currently active.", + "lockdown-activated": "Server Lockdown Activated", + "lockdown-lifted": "Server Lockdown Lifted", + "lockdown-activated-reply": "Lockdown activated. %c channels have been locked.", + "lockdown-lifted-reply": "Lockdown lifted. %c channels have been restored.", + "lockdown-log-description": "**Reason:** %r\n**Triggered by:** %u\n**Type:** %t\n**Affected channels:** %c", + "lockdown-lift-log-description": "**Reason:** %r\n**Lifted by:** %u\n**Restored channels:** %c", + "lockdown-automatic": "Automatic", + "lockdown-manual": "Manual", + "lockdown-system": "System", + "lockdown-auto-lift-reason": "Auto-lift timer expired", + "lockdown-restored": "Lockdown state restored from database after restart", + "lockdown-joinraid-trigger": "Join raid detected", + "lockdown-spam-trigger": "Excessive spam detected", + "lockdown-joingate-trigger": "Excessive join-gate violations detected", + "lockdown-restore-failed": "Failed to restore permissions for channel %c: %e", + "lockdown-users-kicked": "Users Kicked", + "lockdown-users-kicked-description": "%k non-moderator users were disconnected from voice channels.", + "moderate-user-description": "User on who the action should get performed", + "moderate-userid-description": "ID of a user", + "moderate-days-description": "Number of days of messages to delete", + "invalid-days": "Days can only be between 0 and 7 (inclusive)", + "moderate-notes-description": "Notes to set / update", + "moderate-note-id-description": "ID of one of your notes you want to edit (leave blank to create a new one)", + "moderate-warnid-description": "ID of a warn (run /moderate actions to get it)", + "moderate-actions-command-description": "Show all recorded actions against a user", + "report-command-description": "Reports a user and sends a snapshot of the chat to server staff", + "report-reason-description": "Please describe what the user did wrong", + "report-user-description": "User you want to report", + "no-reason": "Not set", + "muterole-not-found": "Could not find muterole. Can not perform this action", + "quarantinerole-not-found": "Could not find quarantinerole. Can not perform this action", + "mute-audit-log-reason": "Got muted by %u because of \"%r\"", + "unmute-audit-log-reason": "Got unmuted by %u because of \"%r\"", + "quarantine-audit-log-reason": "Got quarantined by %u because of \"%r\"", + "kicked-audit-log-reason": "Got kicked by %u because of \"%r\"", + "banned-audit-log-reason": "Got banned by %u because of \"%r\"", + "channelmute-audit-log-reason": "Got channel-mutet by %u because of \"%r\"", + "unchannelmute-audit-log-reason": "The Channel-Mute got removed by %u because of \"%r\"", + "unbanned-audit-log-reason": "Got unbanned by %u because of \"%r\"", + "unquarantine-audit-log-reason": "Got unquarantined by %u because of \"%r\"", + "enforce-quarantine-no-roles-audit-log": "Quarantined users may not hold additional roles", + "unauthorized-quarantine-removal-audit-log": "Quarantine role was removed without /moderate unquarantine — re-applying", + "unauthorized-quarantine-removal-title": "⚠️ Quarantine role removed manually", + "unauthorized-quarantine-removal-description": "%user% had their quarantine role removed outside of `/moderate unquarantine`. The role has been re-applied automatically.", + "unauthorized-quarantine-removal-by": "Removed by", + "unauthorized-quarantine-removal-by-unknown": "Unknown (audit log unavailable)", + "unauthorized-quarantine-removal-original-case": "Original case", + "action-expired": "Action expired", + "auto-mod": "Auto-Mod", + "batch-role-remove-failed": "Could not remove all roles from %i (trying to remove roles one by one): %e", + "batch-role-add-failed": "Could not add all roles to %i (trying to remove roles one by one): %e", + "could-not-remove-role": "Could not remove role %r from %i: %e", + "could-not-add-role": "Could not add role %r to %i: %e", + "reason": "Reason", + "join-gate": "Join-Gate", + "expires-at": "Action expires on", + "action": "Action", + "case": "Case", + "victim": "Victim", + "missing-logchannel": "LogChannel could not be found", + "reached-warns": "Reached %w warns", + "restored-punishment-audit-log-reason": "Restored punishment", + "anti-join-raid": "ANTI-JOIN-RAID", + "raid-detected": "Raid detected", + "joingate-for-everyone": "Join-Gate-Modus: Catch all users", + "account-age-to-low": "Account creation age of %a days is to low (required are more then %c)", + "no-profile-picture": "Account has no profile picture (required)", + "join-gate-fail": "Account failed Join-Gate (%r)", + "blacklisted-word": "Posted blacklisted word in %c", + "invite-sent": "Sent invite in %c", + "anti-spam": "Anti-Spam", + "reached-messages-in-timeframe": "Reached %m (normal) messages in less than %t seconds", + "reached-duplicated-content-messages": "Reached %m messages with the same content in less than %t", + "reached-ping-messages": "Reached %m messages with (user) pings in less then %t seconds", + "reached-massping-messages": "Reached %m messages with mass pings in less than %t seconds", + "action-done": "Executed action successfully. Action-ID: #%i", + "expiring-action-done": "Done. Action will expire on %d. Action-ID: #%i", + "cleared-channel": "Cleared channel successfully.\nNote: Messages older than 14 days can not be deleted using this method.", + "clear-failed": "An error occurred. You can only delete 100 messages at once.", + "no-quarantine-action-found": "Sorry, but I couldn't find any records of quarantining this users.", + "locked-channel-successfully": "Locked channel successfully. Only moderators (and admins) can write messages here now.", + "unlocked-channel-successfully": "Unlocked channel successfully. Permissions got restored to the permission-state before the lock occurred.", + "unlock-audit-log-reason": "User %u unlocked this channel by running /moderate unlock", + "warning-not-found": "I could not find this warning. Please make sure you are actually using a warning-id and not a userid.", + "can-not-report-mod": "You can not report moderators.", + "action-description-format": "%reason\nby %u on %t", + "no-actions-title": "None found", + "no-actions-value": "No actions against %u found.", + "actions-embed-title": "Mod-Actions against %u - Site %i", + "actions-embed-description": "You can find every action against %u here.", + "report-embed-title": "New report", + "report-embed-description": "A user reported another user. Please review the case and take actions if needed.", + "reported-user": "Reported user", + "report-reason": "Reason for the report", + "report-user": "User who submitted report", + "message-log": "Last 100 messages", + "message-log-description": "You can find an encrypted message-log [here](%u).", + "channel": "Channel", + "no-report-pings": "No pings configured. Check your configuration to ping your staff.", + "not-allowed-to-see-own-notes": "Sorry, but you are not allowed to see your own notes.", + "note-added": "Note added successfully", + "note-edited": "Edited note successfully", + "note-deleted": "Note deleted successfully", + "note-not-found-or-no-permissions": "Note not found or no permissions to edit this note.", + "notes-embed-title": "Notes about %u", + "info-field-title": "ℹ️ Information", + "no-notes-found": "No notes about this user. Create a new note with `/moderate notes create` and set the notes attribute.", + "more-notes": "%x other moderator also added notes about this user. Notes are sorted in reverse chronology, so you will see the newest notes first.", + "user-notes-field-title": "%t's notes", + "user-not-on-server": "I can't perform this action on this user, as they are not currently on your server.", + "verification": "VERIFICATION", + "verification-failed": "Verification failed", + "verification-started": "Verification got started", + "verification-completed": "Verification completed", + "user": "User", + "manual-verification-needed": "Manual verification needed", + "verification-deny": "Deny verification", + "verification-approve": "Approve verification", + "verification-skip": "Skip verification", + "captcha-verification-pending": "Captcha-Verification is pending. You can either wait for the user to complete it or skip it manually.", + "verification-update-proceeded": "Successfully update verification status", + "verify-channel-set-but-not-found-or-wrong-type": "The configured verify-channel could not be found or it's type is not supported.", + "generating-message": "We are preparing some stuff, this message should get edited shortly...", + "restart-verification-button": "Restart verification process", + "member-not-found": "This user could not be found, maybe they already left?", + "already-verified": "Seems like you are already verified... Why would you want to repeat this process?", + "restarted-verification": "I have sent you another DM about your verification process. Please read it carefully and follow the actions described in it. Please not that this action did not re-trigger the manual-verification (if enabled), so spamming this button is useless.", + "dms-still-disabled": "It seems like your DMs are still disabled. Please enable your DMs to start the verification. This is not optional, you need to do this in order to get access to %g.", + "dms-not-enabled-ping": "%p, it seems like you have your DMs disabled. Please enable them and hit the button below this message to verify yourself. You have two minutes to complete this process.", + "verify-me-button": "Verify Me", + "enter-solution-button": "Enter Solution", + "verification-submitted": "Your verification request has been submitted. A moderator will review it shortly.", + "already-pending-review": "Your verification request is already being reviewed by a moderator.", + "captcha-expired": "Your captcha has expired. Please click Verify Me again.", + "retry-message": "Wrong answer. You can try again in %t. (Attempt %a/%m)", + "cooldown-message": "⏳ Please wait %t% before trying again.", + "retries-exhausted": "You have exhausted all verification attempts.", + "simple-math-challenge": "What is %a %op %b?", + "simple-word-challenge": "Type the following word: %w", + "captcha-solution-label": "Enter the captcha solution", + "simple-solution-label": "Enter your answer", + "verification-modal-title": "Verification" + }, + "months": { + "1": "January", + "2": "February", + "3": "March", + "4": "April", + "5": "May", + "6": "June", + "7": "July", + "8": "August", + "9": "September", + "10": "October", + "11": "November", + "12": "December" }, "nicknames": { "owner-cannot-be-renamed": "The owner of the server (%u) cannot be renamed.", "nickname-error": "An error occurred while trying to change the nickname of %u: %e" }, + "partner-list": { + "could-not-give-role": "Could not give role to user %u", + "could-not-remove-role": "Could not remove role from user %u", + "list-location": "[Partner List] The partner list is currently located here: %l. Delete the message and restart the bot, to re-send it.", + "partner-not-found": "Partner could not be found. Please check if you are using the right partner-ID. The partner-ID is not identical with the server-id of the partner. The Partner-ID can be found [here](https://gblobscdn.gitbook.com/assets%2F-MNyHzQ4T8hs4m6x1952%2F-MWDvDO9-_JwAGqtD6at%2F-MWDxIcOHB9VcWhjsWt7%2Fscreen_20210320-102628.png?alt=media&token=2f9ac1f7-1a14-445c-b34e-83057789578e) in the partner-embed.", + "successful-edit": "Edited partner-list successfully.", + "channel-not-found": "Could not find channel with ID %c or the channel has a wrong type (only text-channels supported)", + "no-partners": "There are currently no partners. This is odd, but that's how it is ¯\\_(ツ)_/¯\n\nTo add a partner, run `/partner add` as a slash-command.", + "information": "Information", + "command-description": "Manages the partner-list on this server", + "padd-description": "Add a new partner", + "padd-name-description": "Name of the partner", + "padd-category-description": "Please select one of the categories specified in your configuration", + "padd-owner-description": "Owner of the partnered server", + "padd-inviteurl-description": "Invite to the partnered server", + "pedit-description": "Edits an existing partner", + "pedit-id-description": "ID of the partner", + "pedit-name-description": "New name of the partner", + "pedit-inviteurl-description": "New invite to this partner", + "pedit-category-description": "New category of this partner", + "pedit-owner-description": "New owner of the partner server", + "pedit-staff-description": "New designated staff member for this partner server", + "pdelete-description": "Deletes an exiting partner", + "pdelete-id-description": "ID of the partner" + }, + "ping-on-vc-join": { + "channel-not-found": "Notify channel %c not found", + "could-not-send-pn": "Could not send PN to %m" + }, "ping-protection": { "log-not-a-member": "[Ping Protection] Punishment failed: The pinger is not a member.", "log-punish-role-error": "[Ping Protection] Punishment failed: I cannot punish %tag because their role is higher than or equal to my highest role.", @@ -1013,8 +866,11 @@ "field-wl-users": "Whitelisted Users", "list-none": "None are configured.", "modal-title": "Confirm data deletion for this user", + "fallback-modal-title": "Confirm data deletion", "modal-label": "Confirm data deletion by typing this phrase:", + "fallback-modal-label": "Confirm by typing this phrase:", "modal-phrase": "I understand that the data of this user will be deleted and that this action cannot be undone.", + "fallback-modal-phrase": "I confirm the data deletion of this user with risks.", "modal-failed": "The phrase you entered is incorrect. Data deletion cancelled.", "field-quick-history": "Quick history view (Last %w weeks)", "field-quick-desc": "Pings history amount: %p\nModeration actions amount: %m", @@ -1072,14 +928,146 @@ "log-del-type": "[Ping Protection] Deleted %type data for user %target by %admin.", "log-del-all": "[Ping Protection] Deleted all stored data for user %target by %admin." }, - "betterstatus": { - "command-description": "Change the bot's status", - "command-disabled": "The /status command is not enabled. An administrator needs to enable it in the betterstatus module configuration.", - "text-description": "The status text to display", - "activity-type-description": "The activity type (Playing, Watching, etc.)", - "bot-status-description": "The bot's online status (Online, Idle, DND)", - "streaming-link-description": "Streaming URL (only used when activity type is Streaming)", - "status-changed": "Bot status has been changed to: %s" + "polls": { + "what-have-i-votet": "What have I voted?", + "vote": "Vote!", + "vote-this": "Click on this option to place your vote here", + "voted-successfully": "Successfully voted. Thanks for your participation.", + "not-voted-yet": "You have not voted yet, so I can't show you what you voted.", + "you-voted": "You have voted for **%o**.", + "remove-vote": "Remove my vote", + "removed-vote": "Your vote was removed successfully.", + "change-opinion": "You can change your opinion anytime by just selecting something else above the button you just clicked.", + "command-poll-description": "Create and end polls", + "command-poll-create-description": "Create a new poll", + "command-poll-end-description": "Ends an existing poll", + "command-poll-end-msgid-description": "ID of the poll", + "command-poll-create-description-description": "Topic / Description of this poll", + "command-poll-create-channel-description": "Channel in which the poll should get created", + "command-poll-create-option-description": "Option number %o", + "command-poll-create-endAt-description": "Duration of the poll (if not set the poll will not end automatically)", + "command-poll-create-public-description": "If enabled (disabled by default) the votes of users will be displayed publicly", + "command-poll-create-max-selections-description": "Max selections per voter (default 1, set to 0 for unlimited)", + "max-selections-field": "Max selections per voter", + "max-selections-limit": "Each voter may pick up to **%n** options.", + "max-selections-unlimited": "Each voter may pick **any number** of options.", + "created-poll": "Successfully created poll in %c.", + "not-found": "Poll could not be found", + "no-votes-for-this-option": "Nobody voted this option yet", + "ended-poll": "Poll ended successfully", + "view-public-votes": "View current voters", + "not-public": "This poll does not appear to be public, no results can be displayed.", + "poll-private": "🔒 This poll is **anonymous**, meaning that no one can see what you voted (not even the admins).", + "poll-public": "🔓 This poll is **public**, meaning that everyone can see what you voted.", + "not-text-channel": "You need to select a text-channel that is not an announcement-channel." + }, + "quiz": { + "what-have-i-voted": "What have I voted?", + "vote": "Vote!", + "vote-this": "Select this option if you think it's correct.", + "voted-successfully": "Selected successfully.", + "not-voted-yet": "You have not selected an option yet, so I can't show you what you selected.", + "you-voted": "You've selected **%o** as correct answer.", + "change-opinion": "You can change your opinion at any time by selecting another option above the button you just clicked.", + "cannot-change-opinion": "You cannot change your selection as the creator of this quiz disabled it.", + "select-correct": "Select all correct answers", + "this-correct": "Mark this answer as correct", + "cmd-description": "Create or play server quiz", + "cmd-create-normal-description": "Create a quiz with up to 10 answers", + "cmd-create-bool-description": "Create a quiz with true or false answers", + "cmd-play-description": "Play a server quiz", + "cmd-leaderboard-description": "Shows the quiz leaderboard of the server", + "cmd-create-description-description": "Title / description of the quiz", + "cmd-create-channel-description": "Channel in which the quiz should be created", + "cmd-create-endAt-description": "How long the quiz will last", + "cmd-create-option-description": "Option number %o", + "cmd-create-canchange-description": "If the players can change their opinion after voting (default: no)", + "cmd-create-image-description": "Optional http(s) image URL shown above the answer choices", + "cmd-create-headline-description": "Optional embed title shown above the question", + "invalid-image-url": "The image URL must start with http:// or https://.", + "daily-quiz-limit": "You've reached the limit of **%l** daily playable quizzes. You can play again %timestamp.", + "created": "Quiz created successfully in %c.", + "correct-highlighted": "All correct answers were highlighted.", + "answer-correct": "✅ Your answer was correct and you've received one point for the leaderboard!", + "answer-wrong": "❌ Your answer was wrong!", + "bool-true": "Statement is correct", + "bool-false": "Statement is wrong", + "leaderboard-channel-not-found": "The leaderboard channel couldn't be found or it's type is invalid.", + "leaderboard-notation": "**%p. %u**: %xp XP", + "your-rank": "You've collected **%xp** points in quiz!", + "no-rank": "You've never finished a quiz successfully!", + "no-quiz": "No quizzes have been created for this server. Trusted admins can create them on https://scnx.app/glink?page=bot/configuration?query=quiz&file=quiz%7Cconfigs%2FquizList .", + "no-permission": "You don't have enough permissions to create quiz using the command." + }, + "reload": { + "reloading-config": "Reloading configuration…", + "reloading-config-with-name": "User %tag is reloading the configuration…", + "reloaded-config": "Configuration reloaded successfully.\nOut of %totalModules modules, %enabled were enabled, %configDisabled were disabled because their configuration was wrong.", + "reload-failed": "Configuration reloaded failed. Bot shutting down.", + "reload-successful-syncing-commands": "Configuration reloaded successfully, syncing commands, to make sure permissions are up-to-date…", + "reload-failed-message": "**FAILED**\n```%r```\n**Please read your log to find more information**\nThe bot will kill itself now, bye :wave:", + "command-description": "Reloads the configuration" + }, + "reminders": { + "command-description": "Set a reminder for yourself", + "in-description": "After what time should we remind you? (eg. \"2h 30m\")", + "what-description": "What should we remind you about?", + "dm-description": "Should we send you a DM instead of reminding your in this channel?", + "one-minute-in-future": "Your reminder needs to be at least one minute in the future", + "reminder-set": "Reminder set. We'll remind you at %d.", + "snooze-10m": "10 min", + "snooze-30m": "30 min", + "snooze-1h": "1 hour", + "snooze-1d": "1 day", + "snoozed": "Reminder snoozed. We'll remind you again at %d.", + "snooze-not-allowed": "You can only snooze your own reminders." + }, + "rock-paper-scissors": { + "stone": "Stone", + "paper": "Paper", + "scissors": "Scissors", + "won": "won", + "lost": "lost", + "tie": "tie", + "play-again": "Play again", + "challenge-message": "%t, %u challenged you to a game of rock-paper-scissors! Hit the button below to join the game! This invitation will expire in about 2 minutes, so don't hesitate to much.", + "invite-expired": "Sorry, %u, %i didn't accept your request to play rock-paper-scissors in time ):", + "invite-denied": "Sorry, %u, but %i denied your request to play a round of rock-paper-scissors ):", + "rps-title": "Rock Paper Scissors", + "rps-description": "Choose your weapon!", + "its-a-tie-try-again": "It's a tie! Try again!", + "command-description": "Play rock-paper-scissors against the bot or someone in the chat" + }, + "scnx": { + "activating": "Initializing SCNX-Integration…", + "notLongerInSCNX": "Server disabled or not longer in SCNX. Exiting.", + "activated": "SCNX Integration successfully activated. Get ready to enjoy all the benefits of SCNX", + "loggedInAs": "CustomBot %b logged in as \"%u\" on server %s with version %v an plan \"%p\"", + "choose-roles": "Select roles", + "early-access-missing": "This module is currently early access, but neither the server owner nor any of the trusted admins of this server have early access unlocked.", + "early-access-unlocked": "Early Access has been unlocked", + "early-access-locked": "Early Access features are unavailable", + "select-to-run-action": "Select to run action…", + "localeUpdate": "Updated locales", + "localeUpdateSkip": "Skipped locale update", + "reportAbuse": "Report abuse", + "localeFetchFailed": "Could not fetch locales to update them: SCNX returned %s", + "issueTrackingActivated": "Activated Issue-Tracking successfully. Your bot will now report any unfixable issues to the developers.", + "newVersion": "**⬆ New version available: %v 🎉**\n\nTo apply these changes, please restart your bot in your SCNX Dashboard.\nUpdates should be applied as soon as possible, as they include bug-fixes, improvements and new features. You can find a detailed changelog in our Discord-Server.", + "freePlanExpiring-title": "⚠ **%s's free plan is going to expire in 24 hours**", + "freePlanExpiring-description": "The free plan of \"%s\" is going to expire %r (%t). Please either watch an Ad in your SCNX Dashboard or upgrade to a payed plan to keep your bot online and running.\nThank you.", + "freePlanExpiring-upgrade": "Upgrade to paid plan", + "freePlanExpiring-watchAd": "Watch advertisement", + "freePlanExpiring-footer": "Sent to this channel, because you configured SCNX this way. You can edit notification-settings in the Pricing-Panel in your SCNX Dashboard", + "pro-field-reset": "Had to reset the value of the field \"%f\" to its default value, because customization of this field is only available to the \"%p\" plan, but this server has only \"%c\".", + "plan-STARTER": "Starter", + "plan-ACTIVE_GUILD": "Active Guild", + "plan-PRO": "Pro", + "plan-UNLIMITED": "Unlimited", + "plan-PROFESSIONAL": "Professional", + "plan-ENTERPRISE": "Enterprise", + "reduced-dashboard-active": "Reduced dashboard mode is active, meaning that period server data will be transmitted due allow the SCNX Dashboard to work.", + "reduced-dashboard-transmitting": "Transmitted updated server data due to reduced dashboard mode." }, "staff-management-system": { "time-zero": "0 seconds", @@ -1165,8 +1153,10 @@ "succ-ac-end": "✅ Activity check ended manually.", "err-gen-no-user": "❌ Could not find that user.", "del-conf-phrase": "I understand that this will delete the specified data for this user and it cannot be undone.", + "fallback-conf-phrase": "I confirm the data deletion with risks.", "mod-del-title": "Confirm Data Deletion", "mod-del-lbl": "Type confirmation phrase:", + "fallback-del-lbl": "Confirm with phrase:", "del-all-title": "Confirm total data deletion", "del-all-desc": "You are about to delete ALL data for this user. Reminder that this ***cannot be undone***. This is the last chance to back out. If you are sure, click the button below.\nThis action will automatically cancel in 30 seconds.", "btn-conf-del": "Confirm deletion", @@ -1473,5 +1463,186 @@ "err-self-promo": "You can't promote yourself through a black hole of audacity and expect it to work.", "status-expired-auto": "Ended automatically because the status expired.", "label-system": "System" + }, + "stagePrivacy": { + "PUBLIC": "Publicly accessible", + "GUILD_ONLY": "Only server members can join" + }, + "starboard": { + "invalid-minstars": "Invalid minimum stars %stars", + "star-limit": "You've reached the hourly starboard limit of %limitEmoji on the server which is why you cannot react on the message %msgUrl .\nTry again %time!" + }, + "status-role": { + "fulfilled": "Status-role condition is fulfilled", + "not-fulfilled": "Status-role condition is no longer fulfilled" + }, + "suggestions": { + "suggestion-not-found": "Suggestion not found", + "updated-suggestion": "Successfully updated suggestion", + "suggest-description": "Create and comment on suggestions", + "suggest-content": "Content you want to suggest", + "loading": "A wild new suggestion appeared, loading..", + "manage-suggestion-command-description": "Manage suggestions as an admin", + "manage-suggestion-accept-description": "Accepts a suggestion", + "manage-suggestion-deny-description": "Denies a suggestion", + "manage-suggestion-id-description": "ID of the suggestion", + "manage-suggestion-comment-description": "Explain why you made this choice" + }, + "team-list": { + "channel-not-found": "Could not find channel with ID %c or the channel has a wrong type (only text-channels supported)", + "role-not-found": "Could not find role with ID %r", + "no-users-with-role": "No users on this server have the %r role yet.", + "no-roles-selected": "No roles listed yet.", + "offline": "Offline", + "dnd": "Do not disturb", + "idle": "Away", + "online": "Online" + }, + "temp-channels": { + "removed-audit-log-reason": "Removed temp channel, because no one was in it", + "permission-update-audit-log-reason": "Updated permissions, to make sure only people in the VC can see the no-mic-channel", + "created-audit-log-reason": "Created Temp-Channel for %u", + "move-audit-log-reason": "Moved user to their voice channel", + "no-mic-channel-topic": "Welcome to %u's no-mic-channel. You will see this channel as long as you are connected to this temp-channel.", + "disconnect-audit-log-reason": "The old channel of the user could not be found - disconnecting them - hopefully they join again", + "command-description": "Manage your temp-channel", + "mode-subcommand-description": "Change the mode of your channel", + "public-option-description": "If enabled, anyone can join your temp-channel", + "add-subcommand-description": "Add users, that will be able to join your channel, while it is private", + "remove-subcommand-description": "Remove users from you channel", + "add-user-option-description": "The user to be added", + "remove-user-option-description": "The user to be removed", + "list-subcommand-description": "List the users with access to your channel", + "edit-subcommand-description": "Edit various settings of your channel", + "user-limit-option-description": "Change the user-limit of your channel", + "bitrate-option-description": "Change the bitrate of your channel (min. 8000)", + "name-option-description": "Change the name of your channel", + "nsfw-option-description": "Change, whether your channel is age-restricted or not", + "no-added-user": "There are no users to be displayed here", + "nothing-changed": "Your channel already had these settings.", + "no-disconnect": "Couldn't disconnect the user from your channel. This could be due to missing permissions, or the user not being in your voice-channel", + "edit-error": "An error occurred while editing your channel. one or more of your settings couldn't be applied. This could be due to missing permissions or an invalid value.", + "add-user": "Add user", + "remove-user": "Remove user", + "list-users": "List users", + "private-channel": "Private", + "public-channel": "Public", + "edit-channel": "Edit channel", + "add-modal-title": "Add an user to your temp-channel", + "add-modal-prompt": "The user you want to add (tag or user-id)", + "remove-modal-title": "Remove an user from your temp-channel", + "remove-modal-prompt": "The user you want to remove (tag or user-id)", + "edit-modal-title": "Edit your temp-channel", + "edit-modal-nsfw-prompt": "Mark temp-channel as age-restricted?", + "edit-modal-nsfw-placeholder": "\"true\" (yes) or \"false\" (no)", + "edit-modal-nsfw-on": "Yes (age-restricted)", + "edit-modal-nsfw-off": "No (not age-restricted)", + "edit-modal-bitrate-prompt": "Bitrate of your Temp-channel?", + "edit-modal-bitrate-placeholder": "A number over 8000", + "edit-modal-limit-prompt": "Limit of users in your temp-channel", + "edit-modal-limit-placeholder": "Number between 0 and 99; 0 = unlimited", + "edit-modal-name-prompt": "How should your channel be called?", + "edit-modal-name-placeholder": "A very creative channel name", + "edit-modal-username-placeholder": "Username of the user", + "user-not-found": "User not found" + }, + "tic-tac-toe": { + "command-description": "Play tic-tac-toe against someone in the chat", + "user-description": "User to play against", + "challenge-message": "%t, %u challenged you to a game of tic-tac-toe! Hit the button below to join the battle! This invitation will expire in about 2 minutes, so don't hesitate to much.", + "accept-invite": "Join game", + "deny-invite": "No thanks", + "self-invite-not-possible": "Are you really that lonely? Even Simon, a complete introvert with no friends and developer of this bot, can find another user to play tic-tac-toe with... You should be able to do that too, try inviting %r for example, maybe they want to play a round?", + "invite-expired": "Sorry, %u, %i didn't accept your request to play tic-tac-toe in time ):", + "invite-denied": "Sorry, %u, but %i denied your request to play a round of tic-tac-toe ):", + "you-are-not-the-invited-one": "Sorry, but this invite doesn't belong to you. You can start your own game with `/tic-tac-toe`.", + "playing-header": "**TIC-TAC-TOE GAME IS RUNNING**\n\n%u (🟢) VS %i (🟡)\nCurrently on turn: %t\n\n%t, click a button with a white circle below to place your marker", + "win-header": "**TIC-TOE-GAME ENDED**\n\n%u (🟢) VS %i (🟡)\n\n%w won the game - GG!\n\n*You can start a new round by using `/tic-tac-toe`*", + "draw-header": "**TIC-TOE-GAME ENDED**\n\n%u (🟢) VS %i (🟡)\n\nDraw - no one won this game.", + "not-your-turn": "It's not your turn, take a coffee and return later" + }, + "tickets": { + "channel-not-found": "Ticket-Create-Channel could not be found", + "existing-ticket": "You already have a ticket open: %c", + "ticket-created-audit-log": "%u created a new ticket by clicking the button", + "ticket-created": "Successfully created ticket and notified staff. Head over to it: %c", + "no-admin-pings": "No pings configured. Check your configuration to ping your staff.", + "ticket-closed-successfully": "Closed ticket successfully. This channel will be deleted in a few seconds, thanks for reaching out to our support.", + "ticket-closed-audit-log": "%u closed ticket", + "closing-ticket": "Closing ticket as requested by %u...", + "ticket-with-user": "👤 Ticket-User", + "could-not-dm": "Could not DM %u: %r", + "no-log-channel": "Log-Channel not found", + "ticket-log-embed-title": "📎 Ticket %i closed", + "ticket-log": "Ticket-Log", + "ticket-type": "☕ Ticket-Topic", + "ticket-log-value": "Transcript with %n messages can be found [here](%u).", + "closed-by": "👷 Ticket closed by" + }, + "topgg": { + "channel-not-found": "The configured channel with the ID \"%c\" was not found", + "testvote-header": "This was a test vote", + "voterole-reached": "Voted on top.gg and received Voter-Role", + "voterole-ended": "Vote on top.gg expired and got Voter-Role removed", + "opt-in": "Enable notifications when you can vote again", + "opt-out": "Disable notifications when you can vote again", + "opted-in": "Successfully opted in into receiving notifications when you can vote again", + "opted-out": "Successfully opted out of receiving notifications when you can vote again", + "already-opted-in": "You are already opted-in and will receive notifications when you can vote again", + "already-opted-out": "You are already opted-out and will **not** receive notifications when you can vote again", + "voteamount-reached": "The user reached %k votes which resulted in this role to be given.", + "testvote-description": "This vote was triggered in the top.gg dashboard and does not count towards any votecount of anyone and won't be used for reminders." + }, + "twitch-notifications": { + "channel-not-found": "Channel with ID %c could not be found", + "user-not-on-twitch": "Could not find user %u on twitch", + "message-not-found": "No live-message is configured for streamer %s" + }, + "uno": { + "command-description": "Play Uno against users in the chat", + "challenge-message": "%u invites to a round of Uno! Click the button below this message to join! The game starts %timestamp with %count players.", + "not-enough-players": "Not enough players joined for a round of Uno!", + "user-cards": "%u: %cards cards", + "already-joined": "You're already in!", + "view-deck": "View deck", + "draw": "Draw card", + "uno": "Uno!", + "turn": "It's %u turn!", + "update-button": "Update", + "use-drawn": "Do you want to use the drawn card?", + "dont-use-drawn": "Dont use", + "win": "%u won the game! %turns cards were played.", + "win-you": "You've won the game!", + "missing-uno": "⚠️ You must use the Uno! button before you use your second last card!", + "choose-color": "Select a color:", + "pending-draws": "Use a Draw 2/4 card, otherwise you have to draw %count cards!", + "not-ingame": "You're not in this game!", + "skip": "Skip", + "reverse": "Reverse", + "color": "Color choice", + "draw2": "Draw 2", + "colordraw4": "Color choice and draw 4", + "cant-uno": "You cannot use Uno currently.", + "done-uno": "You've called Uno!", + "auto-drawn-skip": "Your turn was skipped because you would have had to draw the cards anyway.", + "start-game": "Start game now", + "not-host": "You're not the host of the game!", + "max-players": "The game is full!", + "previous-cards": "Previous cards: ", + "used-card": "You've already used the card %c! Use the Update button and play a valid card.", + "invalid-card": "You cannot play the card %c right now! Please select a valid card.", + "inactive-warn": "%u, it's your turn in the uno game!", + "inactive-win": "The uno game has ended. %u won as all others have been eliminated!" + }, + "welcomer": { + "channel-not-found": "[welcomer] Channel not found: %c", + "welcome-yourself-error": "Welcome, nice to meet you! This button is reversed for a special member of this server who want's to say \"Hi\" to you ^^", + "assign-role-failed": "[welcomer] Failed to assign join roles to user %u (roles: %r): %e", + "audit-log-reason-join-roles": "Welcomer: assigned configured join roles", + "base-role-sync-start": "[welcomer] Base-role sync starting (%c members in cache)", + "base-role-sync-done": "[welcomer] Base-role sync complete (scanned: %s, granted: %g, skipped: %k, failed: %f)", + "base-role-re-added": "[welcomer] Re-added missing join roles to %u (roles: %r, removed by: %a)", + "base-role-watchdog-revert": "[welcomer] Reverting base-role grant for %u — quarantine appeared post-grant", + "base-role-audit-reason": "Welcomer: ensuring base join roles" } } diff --git a/main.js b/main.js index 6f096984..a904befb 100644 --- a/main.js +++ b/main.js @@ -70,6 +70,8 @@ if (args[0] && args[1]) { } client.locale = process.argv.find(a => a.startsWith('--lang')) ? (process.argv.find(a => a.startsWith('--lang')).split('--lang=')[1] || 'de') : 'en'; +// Locale file names use underscores (e.g. "zh_Hans"), but Intl/toLocale* APIs require BCP 47 tags ("zh-Hans"). Keep both shapes. +client.bcp47Locale = client.locale.replace('_', '-'); module.exports.client = client; log4js.configure({ pm2: process.argv.includes('--pm2-setup'), @@ -160,15 +162,36 @@ let modulesLoaded = false; async function startUp() { if (config.timezone !== process.env.TZ) { process.env.TZ = config.timezone; - logger.info(`Successfully set timezone to ${config.timezone}. The time is ${new Date().toLocaleString(client.locale.split('_')[0])}.`); + logger.info(`Successfully set timezone to ${config.timezone}. The time is ${new Date().toLocaleString(client.bcp47Locale)}.`); } if (scnxSetup) client.scnxHost = client.config.scnxHostOverwirde || 'https://scnx.app'; + // parse-duration v2 is ESM-only. Resolve the dynamic import once now so the + // sync wrapper used across modules has its underlying function available. + await require('./src/functions/parseDuration').init(); if (!modulesLoaded) { modulesLoaded = true; await loadModelsInDir('/src/models'); + const NicknameManager = require('./src/functions/nicknameManager'); + client.nicknameManager = new NicknameManager(client); + client.nicknameManager.install(); await loadModules(); await loadEventsInDir('./src/events'); + // Expose the loaded models on `client` before db.sync so the migration runner + // (which receives `client`) can reach `client.models.DatabaseSchemeVersion`. + // The post-login handler at line ~248 reassigns the same reference; that + // assignment is redundant but harmless. + client.models = models; await db.sync(); + try { + await require('./src/functions/migrations/runMigrations').runAllMigrations(client, { + onMigrationStart: module.exports.migrationStart, + onMigrationEnd: module.exports.migrationEnd + }); + } catch (e) { + logger.fatal(`[migrations] failed: ${e.stack || e}`); + logger.fatal('[migrations] aborting boot to avoid running with a partially migrated schema.'); + process.exit(1); + } } logger.info(localize('main', 'sync-db')); if (scnxSetup) await require('./src/functions/scnx-integration').beforeInit(client); @@ -269,9 +292,9 @@ async function startUp() { client.commands = commands; client.strings = jsonfile.readFileSync(`${confDir}/strings.json`); client.botReadyAt = new Date(); - client.emit('botReady'); await client.guild.members.fetch({withPresences: true}).catch(() => { }); + client.emit('botReady'); if (scnxSetup) await require('./src/functions/scnx-integration').init(client); logger.info(localize('main', 'bot-ready')); if (client.logChannel) client.logChannel.send('🚀 ' + localize('main', 'bot-ready')); @@ -322,6 +345,7 @@ module.exports.migrationEnd = function () { // Starting bot db.authenticate().then(startUp).catch((e) => { logger.fatal(localize('main', 'db-connect-error', {e: e.message || e})); + if (!scnxSetup) console.error(e); process.exit(1); }); diff --git a/modules/admin-tools/commands/roles.js b/modules/admin-tools/commands/roles.js index f0317d7e..1f9dad43 100644 --- a/modules/admin-tools/commands/roles.js +++ b/modules/admin-tools/commands/roles.js @@ -1,5 +1,5 @@ const {localize} = require('../../../src/functions/localize'); -const durationParser = require('parse-duration'); +const durationParser = require('../../../src/functions/parseDuration'); const {createTemporaryRoleAction, createTemporaryRoleChangeAction} = require('../temporaryRoles'); const {client} = require('../../../main'); const {formatDate} = require('../../../src/functions/helpers'); @@ -44,7 +44,7 @@ module.exports.subcommands = { const member = interaction.options.getMember('user'); member.roles.add(interaction.options.getRole('role'), localize('admin-tools', `audit-log-add${interaction.removeDate ? '-duration' : ''}`, { u: interaction.user.username, - t: interaction.removeDate?.toLocaleString(interaction.client.locale.split('_')[0]) + t: interaction.removeDate?.toLocaleString(interaction.client.bcp47Locale) })).then(() => { if (interaction.removeDate) createTemporaryRoleChangeAction(client, 'remove', interaction.removeDate, interaction.options.getRole('role').id, interaction.options.getUser('user').id); interaction.editReply({ @@ -71,7 +71,7 @@ module.exports.subcommands = { const member = interaction.options.getMember('user'); member.roles.remove(interaction.options.getRole('role'), localize('admin-tools', `audit-log-remove${interaction.removeDate ? '-duration' : ''}`, { u: interaction.user.username, - t: interaction.removeDate?.toLocaleString(interaction.client.locale.split('_')[0]) + t: interaction.removeDate?.toLocaleString(interaction.client.bcp47Locale) })).then(() => { if (interaction.removeDate) createTemporaryRoleChangeAction(client, 'add', interaction.removeDate, interaction.options.getRole('role').id, interaction.options.getUser('user').id); interaction.editReply({ diff --git a/modules/admin-tools/commands/stealemote.js b/modules/admin-tools/commands/stealemote.js index 47130c35..e04d13f4 100644 --- a/modules/admin-tools/commands/stealemote.js +++ b/modules/admin-tools/commands/stealemote.js @@ -5,7 +5,7 @@ module.exports.run = async function (interaction) { const content = interaction.options.getString('emote', true); let emote = content.replace('<', '').replace('>', ''); emote = emote.split(':'); - if (!emote[2] || !emote[1]) return interaction.reply({ + if (!emote[2] || !emote[1] || !/^\d+$/.test(emote[2])) return interaction.reply({ content: '⚠️ ' + localize('admin-tools', 'emoji-too-much-data'), ephemeral: true }); diff --git a/modules/admin-tools/events/guildMemberUpdate.js b/modules/admin-tools/events/guildMemberUpdate.js index 7f3dc950..27d08c95 100644 --- a/modules/admin-tools/events/guildMemberUpdate.js +++ b/modules/admin-tools/events/guildMemberUpdate.js @@ -1,5 +1,5 @@ const {createTemporaryRoleChangeAction} = require('../temporaryRoles'); -const durationParser = require('parse-duration'); +const durationParser = require('../../../src/functions/parseDuration'); const {localize} = require('../../../src/functions/localize'); module.exports.run = async function (client, oldMember, newMember) { diff --git a/modules/admin-tools/module.json b/modules/admin-tools/module.json index d4bdd144..64c4fe9d 100644 --- a/modules/admin-tools/module.json +++ b/modules/admin-tools/module.json @@ -2,8 +2,8 @@ "name": "admin-tools", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/admin-tools", "commands-dir": "/commands", diff --git a/modules/afk-system/commands/afk.js b/modules/afk-system/commands/afk.js index 5608c656..0d6fd995 100644 --- a/modules/afk-system/commands/afk.js +++ b/modules/afk-system/commands/afk.js @@ -1,5 +1,5 @@ const {localize} = require('../../../src/functions/localize'); -const {embedType, truncate, formatDiscordUserName} = require('../../../src/functions/helpers'); +const {embedType} = require('../../../src/functions/helpers'); module.exports.subcommands = { 'end': async function (interaction) { @@ -12,19 +12,9 @@ module.exports.subcommands = { ephemeral: true, content: '⚠️ ' + localize('afk-system', 'no-running-session') }); - if (session.nickname) await interaction.member.setNickname(session.nickname, '[afk-system] ' + localize('afk-system', 'afk-nickname-change-audit-log')).catch(e => { - interaction.client.logger.warn(localize('afk-system', 'can-not-edit-nickname', { - e, - u: formatDiscordUserName(interaction.user) - })); - }); - else await interaction.member.setNickname(null, '[afk-system] ' + localize('afk-system', 'afk-nickname-change-audit-log')).catch(e => { - interaction.client.logger.warn(localize('afk-system', 'can-not-edit-nickname', { - e, - u: formatDiscordUserName(interaction.user) - })); - }); await session.destroy(); + interaction.client.nicknameManager.attachMember(interaction.member); + interaction.client.nicknameManager.requestUpdate(interaction.member.id); interaction.reply(embedType(interaction.client.configurations['afk-system']['config']['sessionEndedSuccessfully'], {}, {ephemeral: true})); }, 'start': async function(interaction) { @@ -39,16 +29,11 @@ module.exports.subcommands = { }); await interaction.client.models['afk-system']['AFKUser'].create({ userID: interaction.user.id, - nickname: interaction.member.nickname, afkMessage: interaction.options.getString('reason'), autoEnd: typeof interaction.options.getBoolean('auto-end') === 'boolean' ? interaction.options.getBoolean('auto-end') : true }); - await interaction.member.setNickname('[AFK] ' + truncate(interaction.member.nickname || interaction.user.username, 32 - 6)).catch(e => { - interaction.client.logger.warn(localize('afk-system', 'can-not-edit-nickname', { - e, - u: formatDiscordUserName(interaction.user) - })); - }); + interaction.client.nicknameManager.attachMember(interaction.member); + interaction.client.nicknameManager.requestUpdate(interaction.member.id); interaction.reply(embedType(interaction.client.configurations['afk-system']['config']['sessionStartedSuccessfully'], {}, {ephemeral: true})); } }; diff --git a/modules/afk-system/events/messageCreate.js b/modules/afk-system/events/messageCreate.js index 29a63fac..ed451314 100644 --- a/modules/afk-system/events/messageCreate.js +++ b/modules/afk-system/events/messageCreate.js @@ -1,5 +1,4 @@ -const {embedType, formatDiscordUserName} = require('../../../src/functions/helpers'); -const {localize} = require('../../../src/functions/localize'); +const {embedType} = require('../../../src/functions/helpers'); module.exports.run = async function(client, message) { if (!message.guild) return; @@ -14,19 +13,9 @@ module.exports.run = async function(client, message) { } }); if (userAFK) { - if (userAFK.nickname) await message.member.setNickname(userAFK.nickname, '[afk-system] ' + localize('afk-system', 'afk-nickname-change-audit-log')).catch(e => { - message.client.logger.warn(localize('afk-system', 'can-not-edit-nickname', { - e, - u: formatDiscordUserName(message.author) - })); - }); - else await message.member.setNickname(null, '[afk-system] ' + localize('afk-system', 'afk-nickname-change-audit-log')).catch(e => { - message.client.logger.warn(localize('afk-system', 'can-not-edit-nickname', { - e, - u: formatDiscordUserName(message.author) - })); - }); await userAFK.destroy(); + client.nicknameManager.attachMember(message.member); + client.nicknameManager.requestUpdate(message.member.id); await message.reply(embedType(client.configurations['afk-system']['config']['autoEndMessage'], {'%user%': message.author.toString()}, {allowedMentions: {parse: []}})); } for (const member of message.mentions.members.values()) { diff --git a/modules/afk-system/module.json b/modules/afk-system/module.json index 44d7b73c..edd33ee0 100644 --- a/modules/afk-system/module.json +++ b/modules/afk-system/module.json @@ -2,12 +2,13 @@ "name": "afk-system", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "commands-dir": "/commands", "models-dir": "/models", "events-dir": "/events", + "on-load-event": "onLoad.js", "config-example-files": [ "config.json" ], diff --git a/modules/afk-system/onLoad.js b/modules/afk-system/onLoad.js new file mode 100644 index 00000000..1cee04f9 --- /dev/null +++ b/modules/afk-system/onLoad.js @@ -0,0 +1,17 @@ +module.exports.onLoad = function (client) { + if (client.afkSystemProviderRegistered) return; + client.afkSystemProviderRegistered = true; + + client.nicknameManager.registerProvider('afk', 'afk-system', async (member) => { + const AFKUser = client.models?.['afk-system']?.['AFKUser']; + if (!AFKUser) return null; + const session = await AFKUser.findOne({where: {userID: member.id}}); + if (!session) return null; + return { + source: 'afk', + position: 'wrap', + value: (s) => '[AFK] ' + s, + priority: 500 + }; + }); +}; diff --git a/modules/anti-ghostping/module.json b/modules/anti-ghostping/module.json index cae717b7..b5e04d00 100644 --- a/modules/anti-ghostping/module.json +++ b/modules/anti-ghostping/module.json @@ -2,8 +2,8 @@ "name": "anti-ghostping", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "events-dir": "/events", "fa-icon": "fa fa-bell-exclamation", diff --git a/modules/auto-delete/events/botReady.js b/modules/auto-delete/events/botReady.js index 5a3a11b8..b5845a99 100644 --- a/modules/auto-delete/events/botReady.js +++ b/modules/auto-delete/events/botReady.js @@ -63,4 +63,6 @@ function findUniqueChannels(arrayToFilter) { } return arrayToFilter.filter((channel, index) => uniqueConfigChannelIds[channel.channelID] === index); -} \ No newline at end of file +} + +module.exports.findUniqueChannels = findUniqueChannels; \ No newline at end of file diff --git a/modules/auto-delete/module.json b/modules/auto-delete/module.json index 963de1a6..997db1e4 100644 --- a/modules/auto-delete/module.json +++ b/modules/auto-delete/module.json @@ -2,8 +2,8 @@ "name": "auto-delete", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "fa-icon": "fa-regular fa-trash-can", "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/auto-delete", diff --git a/modules/auto-messager/cronjob.json b/modules/auto-messager/cronjob.json index bd40ed91..313e024b 100644 --- a/modules/auto-messager/cronjob.json +++ b/modules/auto-messager/cronjob.json @@ -31,4 +31,4 @@ "type": "string" } ] -} +} \ No newline at end of file diff --git a/modules/auto-messager/daily.json b/modules/auto-messager/daily.json index b52456cd..a7cbe525 100644 --- a/modules/auto-messager/daily.json +++ b/modules/auto-messager/daily.json @@ -40,4 +40,4 @@ "content": "integer" } ] -} +} \ No newline at end of file diff --git a/modules/auto-messager/hourly.json b/modules/auto-messager/hourly.json index 29b557cb..ca7b5b1b 100644 --- a/modules/auto-messager/hourly.json +++ b/modules/auto-messager/hourly.json @@ -32,4 +32,4 @@ "content": "integer" } ] -} +} \ No newline at end of file diff --git a/modules/auto-messager/module.json b/modules/auto-messager/module.json index 3e073869..d033b6ab 100644 --- a/modules/auto-messager/module.json +++ b/modules/auto-messager/module.json @@ -3,8 +3,8 @@ "fa-icon": "fas fa-comment-dots", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/auto-messager", "events-dir": "/events", diff --git a/modules/auto-publisher/events/messageCreate.js b/modules/auto-publisher/events/messageCreate.js index 1edec8e3..b26947ce 100644 --- a/modules/auto-publisher/events/messageCreate.js +++ b/modules/auto-publisher/events/messageCreate.js @@ -9,7 +9,7 @@ module.exports.run = async (client, msg) => { const config = client.configurations['auto-publisher']['config']; if (config.ignoreBots && msg.author.bot) return; if (!config.blacklist) config.blacklist = []; - if (!config.whitelist) config.blacklist = []; + if (!config.whitelist) config.whitelist = []; if (!config.mode) config.mode = 'all'; if (config.mode === 'blacklist' && config.blacklist.includes(msg.channel.id)) return; if (config.mode === 'whitelist' && !config.whitelist.includes(msg.channel.id)) return; diff --git a/modules/auto-publisher/module.json b/modules/auto-publisher/module.json index 6be0fe8c..14bce7b1 100644 --- a/modules/auto-publisher/module.json +++ b/modules/auto-publisher/module.json @@ -3,8 +3,8 @@ "fa-icon": "fas fa-bullhorn", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/auto-publisher", "events-dir": "/events", diff --git a/modules/auto-thread/module.json b/modules/auto-thread/module.json index 4d93ad0a..870c865d 100644 --- a/modules/auto-thread/module.json +++ b/modules/auto-thread/module.json @@ -3,8 +3,8 @@ "fa-icon": "fa-regular fa-comment", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "events-dir": "/events", "config-example-files": [ diff --git a/modules/betterstatus/module.json b/modules/betterstatus/module.json index dd90089e..42d75fd6 100644 --- a/modules/betterstatus/module.json +++ b/modules/betterstatus/module.json @@ -1,8 +1,8 @@ { "name": "betterstatus", "author": { - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox", + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit", "scnxOrgID": "1" }, "fa-icon": "far fa-user-circle", diff --git a/modules/channel-stats/events/botReady.js b/modules/channel-stats/events/botReady.js index 53814d74..19002340 100644 --- a/modules/channel-stats/events/botReady.js +++ b/modules/channel-stats/events/botReady.js @@ -81,3 +81,6 @@ async function channelNameReplacer(client, channel, input) { .split('%emojiCount%').join(channel.guild.emojis.cache.size) .split('%currentTime%').join(formatDate(new Date(), true)).trim(); } + +// Exported for unit testing of the placeholder-replacement logic. +module.exports.channelNameReplacer = channelNameReplacer; diff --git a/modules/channel-stats/module.json b/modules/channel-stats/module.json index 13f9697c..78d4bd4c 100644 --- a/modules/channel-stats/module.json +++ b/modules/channel-stats/module.json @@ -3,8 +3,8 @@ "fa-icon": "fas fa-stream", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "events-dir": "/events", "config-example-files": [ diff --git a/modules/color-me/commands/color-me.js b/modules/color-me/commands/color-me.js index 41b45cf1..92ebc2d9 100644 --- a/modules/color-me/commands/color-me.js +++ b/modules/color-me/commands/color-me.js @@ -142,7 +142,12 @@ module.exports.subcommands = { await interaction.editReply(embedType(moduleStrings['createdNoIcon'], {})); } } catch (e) { - await interaction.editReply(embedType(moduleStrings['roleLimit'], {})); + if (e && e.code === 30005) { + await interaction.editReply(embedType(moduleStrings['roleLimit'], {})); + return; + } + client.logger.error(`color-me: failed to create role for user ${interaction.user.id} in guild ${interaction.guild.id}: ${e && e.stack ? e.stack : e}`); + throw e; } } @@ -246,6 +251,9 @@ async function color(interaction, moduleStrings) { }; } +// Exported for unit testing of the colour-validation logic. +module.exports.color = color; + /** ** Function to handle the cooldown stuff * @private diff --git a/modules/connect-four/commands/connect-four.js b/modules/connect-four/commands/connect-four.js index b302fbe1..d17a57c1 100644 --- a/modules/connect-four/commands/connect-four.js +++ b/modules/connect-four/commands/connect-four.js @@ -145,6 +145,11 @@ function checkWin(grid, color, position, y) { } } +module.exports.gameMessage = gameMessage; +module.exports.checkWin = checkWin; +module.exports.checkWinDiag = checkWinDiag; +module.exports.checkWinDiagLeft = checkWinDiagLeft; + module.exports.run = async function (interaction) { const member = interaction.options.getMember('user'); if (member.id === interaction.user.id) return interaction.reply({ diff --git a/modules/counter/module.json b/modules/counter/module.json index 0ebed82d..4601dda0 100644 --- a/modules/counter/module.json +++ b/modules/counter/module.json @@ -3,8 +3,8 @@ "fa-icon": "fas fa-arrow-up-1-9", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "events-dir": "/events", "models-dir": "/models", diff --git a/modules/duel/commands/duel.js b/modules/duel/commands/duel.js index 1d133d40..b7e684ea 100644 --- a/modules/duel/commands/duel.js +++ b/modules/duel/commands/duel.js @@ -2,6 +2,31 @@ const {localize} = require('../../../src/functions/localize'); const {ComponentType, MessageEmbed} = require('discord.js'); const {safeSetFooter} = require('../../../src/functions/helpers'); +const DUEL_ACTION_ORDER = ['reload', 'guard', 'gun']; + +/** + * Sorts a pair of duel actions by their canonical priority (reload < guard < gun). + * The sorted pair, joined with '-', is the key used to look up the round result. + * @param {String} a First player's action + * @param {String} b Second player's action + * @returns {Array} The two actions in canonical order + */ +function sortDuelAnswers(a, b) { + return [a, b].sort((x, y) => DUEL_ACTION_ORDER.indexOf(x) - DUEL_ACTION_ORDER.indexOf(y)); +} + +/** + * The duel ends when one player shoots (gun) while the other was reloading. + * @param {Array} sortedAnswers Pair of actions in canonical order + * @returns {Boolean} Whether this round ends the game + */ +function isDuelGameOver(sortedAnswers) { + return sortedAnswers.join('-') === 'reload-gun'; +} + +module.exports.sortDuelAnswers = sortDuelAnswers; +module.exports.isDuelGameOver = isDuelGameOver; + module.exports.run = async function (interaction) { const member = interaction.options.getMember('user', true); if (member.user.id === interaction.user.id) return interaction.reply({ @@ -105,7 +130,7 @@ module.exports.run = async function (interaction) { if (currentAnswers[member.user.id] === 'gun' && guardAfterEachOther[interaction.user.id] >= 5) currentAnswers[interaction.user.id] = 'reload'; if (currentAnswers[interaction.user.id] === 'gun' && guardAfterEachOther[member.user.id] >= 5) currentAnswers[member.user.id] = 'reload'; if ((currentAnswers[interaction.user.id] === 'gun' && guardAfterEachOther[member.user.id] >= 5) || currentAnswers[member.user.id] === 'gun' && guardAfterEachOther[interaction.user.id] >= 5) guardOver = true; - const answers = [currentAnswers[member.user.id], currentAnswers[interaction.user.id]].sort((a, b) => ['reload', 'guard', 'gun'].indexOf(a) - ['reload', 'guard', 'gun'].indexOf(b)); + const answers = sortDuelAnswers(currentAnswers[member.user.id], currentAnswers[interaction.user.id]); const params = {}; const actionTo = { 'reload': 'r', @@ -115,7 +140,7 @@ module.exports.run = async function (interaction) { params[actionTo[currentAnswers[member.user.id]] + '1'] = member.user.toString(); params[actionTo[currentAnswers[interaction.user.id]] + (params[actionTo[currentAnswers[interaction.user.id]] + '1'] ? '2' : '1')] = interaction.user.toString(); lastRoundString = localize('duel', (guardOver ? 'guard-over-' : '') + answers.join('-'), params); - if (answers.join('-') === 'reload-gun') ended = true; + if (isDuelGameOver(answers)) ended = true; currentAnswers = {}; } } diff --git a/modules/duel/module.json b/modules/duel/module.json index 994f6318..ce0ea1e1 100644 --- a/modules/duel/module.json +++ b/modules/duel/module.json @@ -3,8 +3,8 @@ "humanReadableName": "Duel", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "description": "Let users play the game \"Duel\" on your discord", "commands-dir": "/commands", diff --git a/modules/economy-system/commands/economy-system.js b/modules/economy-system/commands/economy-system.js index 5fe1b9f2..10d7e3e3 100644 --- a/modules/economy-system/commands/economy-system.js +++ b/modules/economy-system/commands/economy-system.js @@ -49,7 +49,7 @@ async function cooldown (command, duration, userId, client) { module.exports.subcommands = { 'work': async function (interaction) { if (!await cooldown('work', interaction.config['workCooldown'] * 60000, interaction.user.id, interaction.client)) return interaction.reply(embedType(interaction.str['cooldown'], {}, {ephemeral: !interaction.config['publicCommandReplies']})); - const moneyToAdd = randomIntFromInterval(parseInt(interaction.config['maxWorkMoney']), parseInt(interaction.config['minWorkMoney'])); + const moneyToAdd = randomIntFromInterval(parseInt(interaction.config['minWorkMoney']), parseInt(interaction.config['maxWorkMoney'])); await editBalance(interaction.client, interaction.user.id, 'add', moneyToAdd); interaction.reply(embedType(randomElementFromArray(interaction.str['workSuccess']), {'%earned%': `${moneyToAdd} ${interaction.config['currencySymbol']}`}, {ephemeral: !interaction.config['publicCommandReplies']})); createLeaderboard(interaction.client); @@ -91,7 +91,7 @@ module.exports.subcommands = { c: interaction.config['currencySymbol'] })); } else { - const money = randomIntFromInterval(parseInt(interaction.config['maxCrimeMoney']), parseInt(interaction.config['minCrimeMoney'])); + const money = randomIntFromInterval(parseInt(interaction.config['minCrimeMoney']), parseInt(interaction.config['maxCrimeMoney'])); await editBalance(interaction.client, interaction.user.id, 'add', money); interaction.reply(embedType(randomElementFromArray(interaction.str['crimeSuccess']), {'%earned%': `${money} ${interaction.config['currencySymbol']}`}, {ephemeral: !interaction.config['publicCommandReplies']})); createLeaderboard(interaction.client); @@ -140,8 +140,6 @@ module.exports.subcommands = { }, 'add': async function (interaction) { if (!interaction.client.configurations['economy-system']['config']['admins'].includes(interaction.user.id) && !interaction.client.config['botOperators'].includes(interaction.user.id)) return interaction.reply(embedType(interaction.client.strings['not_enough_permissions'], {}, {ephemeral: !interaction.config['publicCommandReplies']})); - console.log(interaction.options.getUser('user').id); - console.log(interaction.user.id); if (interaction.options.getUser('user').id === interaction.user.id && !interaction.client.configurations['economy-system']['config']['selfBalance']) { if (interaction.client.logChannel) interaction.client.logChannel.send(localize('economy-system', 'admin-self-abuse')); return interaction.reply({ diff --git a/modules/economy-system/economy-system.js b/modules/economy-system/economy-system.js index cd86b4ee..7e54048d 100644 --- a/modules/economy-system/economy-system.js +++ b/modules/economy-system/economy-system.js @@ -617,4 +617,6 @@ module.exports.deleteShopItem = deleteShopItem; module.exports.updateShopItem = updateShopItem; module.exports.createShopMsg = createShopMsg; module.exports.shopMsg = shopMsg; -module.exports.createLeaderboard = leaderboard; \ No newline at end of file +module.exports.createLeaderboard = leaderboard; +module.exports.topTen = topTen; +module.exports.getUser = getUser; \ No newline at end of file diff --git a/modules/economy-system/events/botReady.js b/modules/economy-system/events/botReady.js index 35fce70e..d4e2c784 100644 --- a/modules/economy-system/events/botReady.js +++ b/modules/economy-system/events/botReady.js @@ -1,49 +1,11 @@ const {createLeaderboard, shopMsg} = require('../economy-system'); const schedule = require('node-schedule'); -const {localize} = require('../../../src/functions/localize'); module.exports.run = async function (client) { - // Migration - const dbVersionUser = await client.models['DatabaseSchemeVersion'].findOne({where: {model: 'economy_User'}}); - if (!dbVersionUser) { - client.logger.info('[economy-system] ' + localize('economy-system', 'migration-happening')); - const data = await client.models['economy-system']['Balance'].findAll({attributes: ['id', 'balance']}); - await client.models['economy-system']['Balance'].sync({force: true}); - for (const user of data) { - await client.models['economy-system']['Balance'].create(user); - } - client.logger.info('[economy-system] ' + localize('economy-system', 'migration-done')); - await client.models['DatabaseSchemeVersion'].create({model: 'economy_User', version: 'V1'}); - } - const dbVersionCooldown = await client.models['DatabaseSchemeVersion'].findOne({where: {model: 'economy_Cooldown'}}); - if (!dbVersionCooldown) { - client.logger.info('[economy-system] ' + localize('economy-system', 'migration-happening')); - const data = await client.models['economy-system']['cooldown'].findAll({attributes: ['id', 'command']}); - await client.models['economy-system']['cooldown'].sync({force: true}); - for (const user of data) { - await client.models['economy-system']['cooldown'].create(user); - } - client.logger.info('[economy-system] ' + localize('economy-system', 'migration-done')); - await client.models['DatabaseSchemeVersion'].create({model: 'economy_Cooldown', version: 'V1'}); - } - const dbVersionShop = await client.models['DatabaseSchemeVersion'].findOne({where: {model: 'economy_Shop'}}); - if (!dbVersionShop) { - client.logger.info('[economy-system] ' + localize('economy-system', 'migration-happening')); - const data = await client.models['economy-system']['Shop'].findAll({attributes: ['name', 'price', 'role']}); - await client.models['economy-system']['Shop'].sync({force: true}); - let i = 0; - for (const item of data) { - item['dataValues']['id'] = i; - await client.models['economy-system']['Shop'].create(item['dataValues']); - i++; - } - client.logger.info('[economy-system] ' + localize('economy-system', 'migration-done')); - await client.models['DatabaseSchemeVersion'].create({model: 'economy_Shop', version: 'V1'}); - } await shopMsg(client); await createLeaderboard(client); const job = schedule.scheduleJob('1 0 * * *', async () => { // Every day at 00:01 https://crontab.guru/#0_0_*_*_ await createLeaderboard(client); }); client.jobs.push(job); -}; \ No newline at end of file +}; diff --git a/modules/economy-system/events/interactionCreate.js b/modules/economy-system/events/interactionCreate.js index 127b1200..7b40565b 100644 --- a/modules/economy-system/events/interactionCreate.js +++ b/modules/economy-system/events/interactionCreate.js @@ -6,6 +6,5 @@ module.exports.run = async function (client, interaction) { if (!interaction.isSelectMenu()) return; if (interaction.customId !== 'economy-system_shop-select') return; await interaction.deferReply({ephemeral: true}); - console.log(interaction.values); buyShopItem(interaction, interaction.values[0], null); }; \ No newline at end of file diff --git a/modules/economy-system/migrations/economy_Cooldown__V1.js b/modules/economy-system/migrations/economy_Cooldown__V1.js new file mode 100644 index 00000000..591c8786 --- /dev/null +++ b/modules/economy-system/migrations/economy_Cooldown__V1.js @@ -0,0 +1,39 @@ +const {DataTypes} = require('sequelize'); + +const TABLE = 'economy_cooldowns'; + +module.exports = { + tables: [TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (!description.userId) { + await queryInterface.addColumn(TABLE, 'userId', { + type: DataTypes.STRING + }, {transaction}); + } + if (!description.timestamp) { + await queryInterface.addColumn(TABLE, 'timestamp', { + type: DataTypes.DATE + }, {transaction}); + } + }); + }, + down: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.timestamp) await queryInterface.removeColumn(TABLE, 'timestamp', {transaction}); + if (description.userId) await queryInterface.removeColumn(TABLE, 'userId', {transaction}); + }); + } +}; \ No newline at end of file diff --git a/modules/economy-system/migrations/economy_Shop__V1.js b/modules/economy-system/migrations/economy_Shop__V1.js new file mode 100644 index 00000000..415efc7d --- /dev/null +++ b/modules/economy-system/migrations/economy_Shop__V1.js @@ -0,0 +1,60 @@ +const TABLE = 'economy_shop'; + +/* + * V1 commit (98e3b4f4, Oct 2024) changed the primary key from `name` to a new `id` + * column. The pre-V1 schema had no `id` column at all: `name` was the STRING PK. + * The current model is `id STRING PRIMARY KEY, name STRING, price INTEGER, role TEXT`. + * + * The old inline V1 did `findAll → sync({force:true}) → re-insert with i++` to perform + * this PK swap. Customers who ran that inline V1 have the new schema and their existing + * rows received sequential integer-as-string ids. Customers who never ran it (e.g. they + * upgraded straight from pre-V1 code to this new Umzug-based code) still have the old + * `name`-as-PK table; their shop queries by `id` would silently fail. + * + * SQLite has no `ALTER TABLE ... DROP PRIMARY KEY`, so this migration uses the canonical + * SQLite table-rebuild pattern: create a new table with the right schema, copy the rows + * across, drop the old, rename the new. For data that came from the pre-V1 `name`-as-PK + * schema, we use `name` itself as the new `id` value — that's the stablest mapping + * (it's already unique, and operator-facing identifiers tend to reference items by + * name in the bot's config). + * + * Idempotent: if `id` already exists in the table description (post-V1, fresh install, + * or already-migrated), the body is a no-op. + */ +module.exports = { + tables: [TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.id) return; + + await sequelize.transaction(async (transaction) => { + await sequelize.query(`CREATE TABLE "${TABLE}_new" ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255), + price INTEGER, + role TEXT, + "createdAt" DATETIME, + "updatedAt" DATETIME + )`, {transaction}); + + await sequelize.query(`INSERT INTO "${TABLE}_new" (id, name, price, role, "createdAt", "updatedAt") + SELECT name, name, price, role, "createdAt", "updatedAt" FROM "${TABLE}"`, {transaction}); + + await sequelize.query(`DROP TABLE "${TABLE}"`, {transaction}); + await sequelize.query(`ALTER TABLE "${TABLE}_new" RENAME TO "${TABLE}"`, {transaction}); + }); + }, + down: async () => { + + /* + * No-op: reverting from `id`-PK back to `name`-PK is not a meaningful rollback + * once the runtime code expects `id`. Operators should restore from a backup + * (`migration-backups/__economy_Shop__V1__economy_shop.json`) instead. + */ + } +}; \ No newline at end of file diff --git a/modules/economy-system/migrations/economy_User__V1.js b/modules/economy-system/migrations/economy_User__V1.js new file mode 100644 index 00000000..0f2718b9 --- /dev/null +++ b/modules/economy-system/migrations/economy_User__V1.js @@ -0,0 +1,33 @@ +const {DataTypes} = require('sequelize'); + +const TABLE = 'economy_user'; + +module.exports = { + tables: [TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (!description.bank) { + await queryInterface.addColumn(TABLE, 'bank', { + type: DataTypes.INTEGER + }, {transaction}); + } + }); + }, + down: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.bank) await queryInterface.removeColumn(TABLE, 'bank', {transaction}); + }); + } +}; \ No newline at end of file diff --git a/modules/fun/module.json b/modules/fun/module.json index 683f1e6c..bd771711 100644 --- a/modules/fun/module.json +++ b/modules/fun/module.json @@ -3,8 +3,8 @@ "fa-icon": "fas fa-laugh-squint", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "commands-dir": "/commands", "config-example-files": [ diff --git a/modules/guess-the-number/module.json b/modules/guess-the-number/module.json index 67556dbf..c00daba3 100644 --- a/modules/guess-the-number/module.json +++ b/modules/guess-the-number/module.json @@ -2,8 +2,8 @@ "name": "guess-the-number", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "commands-dir": "/commands", "fa-icon": "fas fa-dice-five", diff --git a/modules/info-commands/commands/info.js b/modules/info-commands/commands/info.js index f5bbd7a2..289ae72e 100644 --- a/modules/info-commands/commands/info.js +++ b/modules/info-commands/commands/info.js @@ -11,7 +11,6 @@ const { } = require('../../../src/functions/helpers'); const {ChannelType, MessageEmbed} = require('discord.js'); const {AgeFromDate} = require('age-calculator'); -const {stringNames} = require('../../invite-tracking/events/guildMemberJoin'); const {calculateLevelXP, isMaxLevel, displayLevel} = require('../../levels/events/messageCreate'); const legacyChannelType = (type) => { @@ -31,6 +30,8 @@ const legacyChannelType = (type) => { return map[type] || (ChannelType[type] ? ChannelType[type].toString().toUpperCase() : type); }; +module.exports.legacyChannelType = legacyChannelType; + // THIS IS PAIN. Rewrite it as soon as possible module.exports.beforeSubcommand = async function (interaction) { await interaction.deferReply({ephemeral: true}); @@ -200,22 +201,6 @@ module.exports.subcommands = { embed.addField(moduleStrings.userinfo.level, displayLevel(levelUserData.level, interaction.client), true); embed.addField(moduleStrings.userinfo.messages, levelUserData.messages.toString(), true); } - if (moduleEnabled(interaction.client, 'invite-tracking')) { - const invitedUsers = await interaction.client.models['invite-tracking']['UserInvite'].findAll({ - where: { - inviter: member.user.id - } - }); - const userInvites = await interaction.client.models['invite-tracking']['UserInvite'].findAll({ - where: { - userID: member.user.id, - left: false - }, - order: [['createdAt', 'DESC']] - }); - if (userInvites[0]) embed.addField(moduleStrings.userinfo['invited-by'], `${localize('invite-tracking', stringNames[userInvites[0].inviteType])}${userInvites[0].inviter ? ` by <@${userInvites[0].inviter}>` : ''}`, true); - embed.addField(moduleStrings.userinfo.invites, `\`\`\`| ${localize('info-commands', 'total-invites')} | ${localize('info-commands', 'active-invites')} | ${localize('info-commands', 'left-invites')} |\n| ${pufferStringToSize(invitedUsers.length.toString(), localize('info-commands', 'total-invites').length)} | ${pufferStringToSize(invitedUsers.filter(i => !i.left).length.toString(), localize('info-commands', 'active-invites').length)} | ${pufferStringToSize(invitedUsers.filter(i => i.left).length.toString(), localize('info-commands', 'left-invites').length)} |\`\`\``); - } let permstring = ''; member.permissions.toArray().forEach(p => { if (!member.permissions.toArray().includes('ADMINISTRATOR')) permstring = permstring + `${p}, `; diff --git a/modules/info-commands/module.json b/modules/info-commands/module.json index 54c89806..20e76015 100644 --- a/modules/info-commands/module.json +++ b/modules/info-commands/module.json @@ -3,8 +3,8 @@ "fa-icon": "fa-solid fa-circle-info", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "commands-dir": "/commands", "config-example-files": [ diff --git a/modules/levels/commands/calculate-level.js b/modules/levels/commands/calculate-level.js new file mode 100644 index 00000000..c27248ff --- /dev/null +++ b/modules/levels/commands/calculate-level.js @@ -0,0 +1,128 @@ +const { + formatNumber, + parseEmbedColor, + safeSetFooter +} = require('../../../src/functions/helpers'); +const {MessageEmbed} = require('discord.js'); +const {localize} = require('../../../src/functions/localize'); +const {calculateLevelXP} = require('../events/messageCreate'); + +const formulaStrings = { + 'EXPONENTIAL': 'x * 750 + ((x - 1) * 500)', + 'LINEAR': 'x * 750', + 'EXPONENTIATION': '350 * (x - 1) ^ 2' +}; + +/** + * Returns the human-readable string of the configured level formula + * @private + * @param {Object} moduleConfig + * @returns {string} + */ +function getFormulaString(moduleConfig) { + if (moduleConfig.curveType === 'CUSTOM') { + return moduleConfig.customLevelCurve || formulaStrings['EXPONENTIAL']; + } + return formulaStrings[moduleConfig.curveType] || formulaStrings['EXPONENTIAL']; +} + +module.exports.run = async function (interaction) { + const moduleStrings = interaction.client.configurations['levels']['strings']; + const moduleConfig = interaction.client.configurations['levels']['config']; + + const requestedLevel = interaction.options.getInteger('level'); + const startFromZero = !!moduleConfig.startFromZero; + const minRequested = startFromZero ? 0 : 1; + + if (requestedLevel < minRequested || requestedLevel > 1000000) { + return interaction.reply({ + ephemeral: true, + content: '⚠️ ' + localize('levels', 'level-out-of-range') + }); + } + + if (moduleConfig.maximumLevelEnabled && requestedLevel > moduleConfig.maximumLevel) { + return interaction.reply({ + ephemeral: true, + content: '⚠️ ' + localize('levels', 'calculate-level-above-max', { + requested: formatNumber(requestedLevel), + max: formatNumber(moduleConfig.maximumLevel) + }) + }); + } + + const internalLevel = requestedLevel + (startFromZero ? 1 : 0); + + let xpNeeded; + if (internalLevel <= 1) { + xpNeeded = 0; + } else { + try { + xpNeeded = calculateLevelXP(interaction.client, internalLevel); + } catch (e) { + return interaction.reply({ + ephemeral: true, + content: '⚠️ ' + localize('levels', 'invalid-custom-formula') + }); + } + } + + const minXP = moduleConfig['min-xp']; + const maxXP = moduleConfig['max-xp']; + if (!minXP || !maxXP) return interaction.reply({ + ephemeral: true, + content: '⚠️ ' + localize('levels', 'calculate-level-zero-xp-range') + }); + const avgXP = (minXP + maxXP) / 2; + + const minMessages = Math.ceil(xpNeeded / maxXP); + const avgMessages = Math.ceil(xpNeeded / avgXP); + const maxMessages = Math.ceil(xpNeeded / minXP); + + const formulaString = getFormulaString(moduleConfig); + + const embed = new MessageEmbed() + .setColor(parseEmbedColor((moduleStrings.leaderboardEmbed && moduleStrings.leaderboardEmbed.color) || 'GREEN')) + .setTitle(localize('levels', 'calculate-level-embed-title', {l: formatNumber(requestedLevel)})) + .addField(localize('levels', 'calculate-level-formula'), `\`${formulaString}\``, false) + .addField(localize('levels', 'calculate-level-xp-needed', {l: formatNumber(requestedLevel)}), formatNumber(xpNeeded), false) + .addField(localize('levels', 'calculate-level-messages-needed', {l: formatNumber(requestedLevel)}), localize('levels', 'calculate-level-messages-value', { + min: formatNumber(minMessages), + avg: formatNumber(avgMessages), + max: formatNumber(maxMessages) + }), false); + + const voiceXPPerMinute = parseFloat(moduleConfig.voiceXPPerMinute); + if (voiceXPPerMinute > 0) { + const voiceMinutes = Math.ceil(xpNeeded / voiceXPPerMinute); + embed.addField( + localize('levels', 'calculate-level-voice-needed', {l: formatNumber(requestedLevel)}), + localize('levels', 'calculate-level-voice-value', {minutes: formatNumber(voiceMinutes)}), + false + ); + } + + safeSetFooter(embed, interaction.client); + + interaction.reply({ + ephemeral: true, + embeds: [embed] + }); +}; + +module.exports.config = { + name: 'calculate-level', + description: localize('levels', 'calculate-level-command-description'), + disabled: function (client) { + return !client.configurations['levels']['config'].enableLevelCalculator; + }, + options: [ + { + type: 'INTEGER', + name: 'level', + description: localize('levels', 'calculate-level-level-description'), + required: true, + minValue: 0 + } + ] +}; diff --git a/modules/levels/commands/leaderboard.js b/modules/levels/commands/leaderboard.js index 9aa104f7..360a1ca2 100644 --- a/modules/levels/commands/leaderboard.js +++ b/modules/levels/commands/leaderboard.js @@ -110,7 +110,7 @@ module.exports.run = async function (interaction) { } } - sendMultipleSiteButtonMessage(interaction.channel, sites, [interaction.user.id], interaction); + sendMultipleSiteButtonMessage(interaction.channel, sites, [interaction.user.id], interaction, true); }; module.exports.config = { diff --git a/modules/levels/commands/profile.js b/modules/levels/commands/profile.js index 576ff9fd..97ff62f9 100644 --- a/modules/levels/commands/profile.js +++ b/modules/levels/commands/profile.js @@ -3,7 +3,9 @@ const { formatDate, formatNumber, parseEmbedColor, - safeSetFooter + safeSetFooter, + formatVoiceDuration, + todayInServerTZ } = require('../../../src/functions/helpers'); const {MessageEmbed} = require('discord.js'); const {localize} = require('../../../src/functions/localize'); @@ -40,6 +42,12 @@ module.exports.run = async function (interaction) { .addField(moduleStrings.embed.xp, `${formatNumber(isMaxLevel(user.level, interaction.client) ? calculateLevelXP(interaction.client, interaction.client.configurations['levels']['config'].maximumLevel) : user.xp)}/${isMaxLevel(user.level, interaction.client) ? '∞' : formatNumber(nextLevelXp)}`, true) .addField(moduleStrings.embed.level, displayLevel(user.level, interaction.client), true); + const today = todayInServerTZ(); + const dailyMessages = user.dailyResetDate === today ? user.dailyMessages : 0; + const dailyVoiceSeconds = user.dailyResetDate === today ? user.dailyVoiceSeconds : 0; + embed.addField(moduleStrings.embed.messagesToday, formatNumber(dailyMessages), true); + embed.addField(moduleStrings.embed.voiceTimeToday, formatVoiceDuration(dailyVoiceSeconds), true); + safeSetFooter(embed, interaction.client); const roleFactor = getMemberRoleFactor(member); diff --git a/modules/levels/configs/config.json b/modules/levels/configs/config.json index 5369b6e2..7f5bf02b 100644 --- a/modules/levels/configs/config.json +++ b/modules/levels/configs/config.json @@ -259,6 +259,14 @@ "type": "boolean", "category": "general" }, + { + "name": "enableLevelCalculator", + "humanName": "Enable /calculate-level command", + "default": false, + "description": "If enabled, server members can use the /calculate-level command to calculate the XP and amount of messages required to reach a specific level based on the configured level curve and XP-per-message range.", + "type": "boolean", + "category": "general" + }, { "name": "allowCheats", "humanName": "Cheats", diff --git a/modules/levels/configs/strings.json b/modules/levels/configs/strings.json index e6456b64..61db20b6 100644 --- a/modules/levels/configs/strings.json +++ b/modules/levels/configs/strings.json @@ -23,6 +23,8 @@ "level": "Level", "joinedAt": "Joined server", "roleFactor": "Role Factor(s)", + "messagesToday": "Messages today", + "voiceTimeToday": "Voice time today", "color": "GREEN" }, "description": "Embed which gets send if !profile gets executed", @@ -86,6 +88,34 @@ { "name": "newLevel", "description": "New level of the user" + }, + { + "name": "xpGained", + "description": "XP gained from this action" + }, + { + "name": "xpType", + "description": "Type of XP gain (Message or Voice)" + }, + { + "name": "totalXP", + "description": "Total XP after gaining XP" + }, + { + "name": "nextLevelXP", + "description": "XP needed for the next level" + }, + { + "name": "totalMessages", + "description": "Lifetime message count" + }, + { + "name": "messagesToday", + "description": "Messages sent today (resets at midnight)" + }, + { + "name": "voiceTimeToday", + "description": "Voice time today (resets at midnight)" } ], "category": "general" @@ -123,6 +153,34 @@ { "name": "role", "description": "Mention of the role (No ping)" + }, + { + "name": "xpGained", + "description": "XP gained from this action" + }, + { + "name": "xpType", + "description": "Type of XP gain (Message or Voice)" + }, + { + "name": "totalXP", + "description": "Total XP after gaining XP" + }, + { + "name": "nextLevelXP", + "description": "XP needed for the next level" + }, + { + "name": "totalMessages", + "description": "Lifetime message count" + }, + { + "name": "messagesToday", + "description": "Messages sent today (resets at midnight)" + }, + { + "name": "voiceTimeToday", + "description": "Voice time today (resets at midnight)" } ], "category": "general" diff --git a/modules/levels/events/messageCreate.js b/modules/levels/events/messageCreate.js index b721788d..4223c6c6 100644 --- a/modules/levels/events/messageCreate.js +++ b/modules/levels/events/messageCreate.js @@ -2,7 +2,11 @@ const { embedType, randomIntFromInterval, randomElementFromArray, - embedTypeV2, formatDiscordUserName, formatNumber + embedTypeV2, + formatDiscordUserName, + formatNumber, + todayInServerTZ, + formatVoiceDuration } = require('../../../src/functions/helpers'); const {ChannelType} = require('discord.js'); @@ -59,7 +63,7 @@ function getMemberRoleFactor(member) { module.exports.getMemberRoleFactor = getMemberRoleFactor; -async function grantXPAndLevelUP(client, member, xp, xpType, channel, msg = null) { +async function grantXPAndLevelUP(client, member, xp, xpType, channel, msg = null, voiceSeconds = 0) { const moduleConfig = client.configurations['levels']['config']; if (member.roles.cache.some(r => moduleConfig.blacklistedRoles.some(br => String(br) === r.id))) return; const moduleStrings = client.configurations['levels']['strings']; @@ -80,6 +84,15 @@ async function grantXPAndLevelUP(client, member, xp, xpType, channel, msg = null if (isMaxLevel(user.level, client)) return; if (xpType === 'message') user.messages = user.messages + 1; + const today = todayInServerTZ(); + if (user.dailyResetDate !== today) { + user.dailyMessages = 0; + user.dailyVoiceSeconds = 0; + user.dailyResetDate = today; + } + if (xpType === 'message') user.dailyMessages = user.dailyMessages + 1; + if (xpType === 'voice' && voiceSeconds > 0) user.dailyVoiceSeconds = user.dailyVoiceSeconds + Math.round(voiceSeconds); + const nextLevelXp = calculateLevelXP(client, user.level + 1); @@ -151,7 +164,14 @@ async function grantXPAndLevelUP(client, member, xp, xpType, channel, msg = null '%username%': member.user.username, '%newLevel%': displayLevel(user.level, client), '%role%': isRewardMessage ? `<@&${moduleConfig.reward_roles[calculatedLevel.toString()]}>` : localize('levels', 'no-role'), - '%tag%': formatDiscordUserName(member.user) + '%tag%': formatDiscordUserName(member.user), + '%xpGained%': formatNumber(Math.round(xp)), + '%xpType%': localize('levels', xpType === 'voice' ? 'xp-type-voice' : 'xp-type-message'), + '%totalXP%': formatNumber(user.xp), + '%nextLevelXP%': formatNumber(calculateLevelXP(client, user.level + 1)), + '%totalMessages%': formatNumber(user.messages), + '%messagesToday%': formatNumber(user.dailyMessages), + '%voiceTimeToday%': formatVoiceDuration(user.dailyVoiceSeconds) }, {allowedMentions: {parse: ['users']}})); await user.save(); currentlyLevelingUp.delete(member.user.id); @@ -196,4 +216,4 @@ module.exports.run = async (client, msg) => { setTimeout(() => { cooldown.delete(msg.author.id); }, moduleConfig.cooldown); -}; +}; \ No newline at end of file diff --git a/modules/levels/events/voiceStateUpdate.js b/modules/levels/events/voiceStateUpdate.js index 7641a1f3..5ccc8143 100644 --- a/modules/levels/events/voiceStateUpdate.js +++ b/modules/levels/events/voiceStateUpdate.js @@ -8,15 +8,21 @@ function isChannelBlacklisted(client, channel) { return blacklist.includes(channel.id) || blacklist.includes(channel.parentId) || blacklist.includes(channel.parent?.parentId); } +module.exports.isChannelBlacklisted = isChannelBlacklisted; + function isRoleBlacklisted(client, member) { return member.roles.cache.some(r => client.configurations['levels']['config'].blacklistedRoles.some(br => String(br) === r.id)); } +module.exports.isRoleBlacklisted = isRoleBlacklisted; + function hasHumanCompany(channel) { if (!channel) return false; return channel.members.filter(m => !m.user.bot).size >= 2; } +module.exports.hasHumanCompany = hasHumanCompany; + function isEligible(client, voiceState) { if (!voiceState || !voiceState.channel) return false; if (!voiceState.member || voiceState.member.user.bot) return false; @@ -28,6 +34,8 @@ function isEligible(client, voiceState) { return true; } +module.exports.isEligible = isEligible; + async function startVoiceSession(client, voiceState) { if (states.has(voiceState.member.id)) return; @@ -68,7 +76,8 @@ async function grantXP(client, member, overrideStateData) { const moduleConfig = client.configurations['levels']['config']; const timeInMinutes = (diff / (1000 * 60)); const xp = Math.round(moduleConfig['voiceXPPerMinute'] * timeInMinutes); - await grantXPAndLevelUP(client, member, xp, 'voice', stateData.channel); + const voiceSeconds = Math.round(diff / 1000); + await grantXPAndLevelUP(client, member, xp, 'voice', stateData.channel, null, voiceSeconds); } async function updateChannelSessions(client, channel) { diff --git a/modules/levels/leaderboardChannel.js b/modules/levels/leaderboardChannel.js index bea25520..667dfca5 100644 --- a/modules/levels/leaderboardChannel.js +++ b/modules/levels/leaderboardChannel.js @@ -53,13 +53,15 @@ module.exports.updateLeaderBoard = async function (client, force = false) { const member = channel.guild.members.cache.get(user.userID); if (!member) continue; if (i >= client.configurations['levels']['config']['leaderboard-channel-max-amount']) continue; - i++; - leaderboardString = leaderboardString + localize('levels', 'leaderboard-notation', { - p: i, + const entry = localize('levels', 'leaderboard-notation', { + p: i + 1, u: client.configurations['levels']['config']['useTags'] ? formatDiscordUserName(member.user) : member.user.toString(), l: displayLevel(user.level, client), xp: formatNumber(isMaxLevel(user.level, client) ? calculateLevelXP(client, client.configurations['levels']['config'].maximumLevel - 1) : user.xp) }) + '\n'; + if (leaderboardString.length + entry.length > 1024) break; + leaderboardString += entry; + i++; } if (leaderboardString.length === 0) leaderboardString = localize('levels', 'no-user-on-leaderboard'); diff --git a/modules/levels/migrations/levels_User__V1.js b/modules/levels/migrations/levels_User__V1.js new file mode 100644 index 00000000..26ae0eb6 --- /dev/null +++ b/modules/levels/migrations/levels_User__V1.js @@ -0,0 +1,51 @@ +const {DataTypes} = require('sequelize'); + +const TABLE = 'levels_users'; + +module.exports = { + tables: [TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + + if (!description.dailyMessages) { + await queryInterface.addColumn(TABLE, 'dailyMessages', { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false + }, {transaction}); + } + if (!description.dailyVoiceSeconds) { + await queryInterface.addColumn(TABLE, 'dailyVoiceSeconds', { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false + }, {transaction}); + } + if (!description.dailyResetDate) { + await queryInterface.addColumn(TABLE, 'dailyResetDate', { + type: DataTypes.STRING, + allowNull: true + }, {transaction}); + } + }); + }, + down: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.dailyResetDate) await queryInterface.removeColumn(TABLE, 'dailyResetDate', {transaction}); + if (description.dailyVoiceSeconds) await queryInterface.removeColumn(TABLE, 'dailyVoiceSeconds', {transaction}); + if (description.dailyMessages) await queryInterface.removeColumn(TABLE, 'dailyMessages', {transaction}); + }); + } +}; \ No newline at end of file diff --git a/modules/levels/models/User.js b/modules/levels/models/User.js index 324e7218..bc83bd88 100644 --- a/modules/levels/models/User.js +++ b/modules/levels/models/User.js @@ -16,6 +16,20 @@ module.exports = class LevelsUser extends Model { level: { type: DataTypes.INTEGER, defaultValue: 1 + }, + dailyMessages: { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false + }, + dailyVoiceSeconds: { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false + }, + dailyResetDate: { + type: DataTypes.STRING, + allowNull: true } }, { tableName: 'levels_users', diff --git a/modules/levels/module.json b/modules/levels/module.json index fe94d622..28aeadf7 100644 --- a/modules/levels/module.json +++ b/modules/levels/module.json @@ -3,8 +3,8 @@ "humanReadableName": "Level-System", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/levels", "commands-dir": "/commands", diff --git a/modules/massrole/commands/massrole.js b/modules/massrole/commands/massrole.js index c546e2ec..282ce959 100644 --- a/modules/massrole/commands/massrole.js +++ b/modules/massrole/commands/massrole.js @@ -206,8 +206,11 @@ function checkTarget(interaction) { } else if (interaction.options.getString('target') === 'humans') { target = 'humans'; } + return target; } +module.exports.checkTarget = checkTarget; + module.exports.config = { name: 'massrole', diff --git a/modules/moderation/configs/config.json b/modules/moderation/configs/config.json index 4ab12fd3..b151007a 100644 --- a/modules/moderation/configs/config.json +++ b/modules/moderation/configs/config.json @@ -132,34 +132,6 @@ "dependsOn": "action_on_invite", "category": "automod" }, - { - "name": "action_on_scam_link", - "humanName": "Action on Scam-Link", - "default": "none", - "description": "What should the bot do if someone posts an suspicious or confirmed scam link?", - "type": "select", - "content": [ - "none", - "warn", - "mute", - "kick", - "quarantine", - "ban" - ], - "category": "automod" - }, - { - "name": "scam_link_level", - "humanName": "Level of Scam-Link-Detection", - "default": "confirmed", - "description": "Select the Level of Scam-Link-Filter. \"confirmed\" only contains verified Scam-Domains, while \"suspicious\" may contain not-harmful domains.", - "type": "select", - "content": [ - "confirmed", - "suspicious" - ], - "category": "automod" - }, { "name": "whitelisted_channels_for_invite_blocking", "humanName": "Whitelisted channels for invite-ban", diff --git a/modules/moderation/events/messageCreate.js b/modules/moderation/events/messageCreate.js index bc85ac70..40f1ae9b 100644 --- a/modules/moderation/events/messageCreate.js +++ b/modules/moderation/events/messageCreate.js @@ -2,7 +2,6 @@ const {moderationAction} = require('../moderationActions'); const {activateLockdown, isLockdownActive} = require('../lockdown'); const {embedType} = require('../../../src/functions/helpers'); const {localize} = require('../../../src/functions/localize'); -const stopPhishing = require('stop-discord-phishing'); // Cache resolved invite codes to guild IDs to avoid repeated API calls const inviteGuildCache = new Map(); @@ -115,13 +114,6 @@ async function performBadWordAndInviteProtection(msg) { const moduleConfig = msg.client.configurations['moderation']['config']; const roles = Array.from(msg.member.roles.cache.filter(f => !f.managed).keys()); if (msg.member.roles.cache.find(r => moduleConfig['moderator-roles_level2'].includes(r.id) || moduleConfig['moderator-roles_level3'].includes(r.id) || moduleConfig['moderator-roles_level4'].includes(r.id))) return; - if (moduleConfig['action_on_scam_link'] !== 'none') { - if (await stopPhishing.checkMessage(msg.content, moduleConfig['action_on_scam_link'] === 'suspicious')) { - await msg.delete(); - await moderationAction(msg.client, moduleConfig['action_on_scam_link'], msg.client, msg.member, localize('moderation', 'scam-url-sent', {c: msg.channel.toString()}), {roles}); - return; - } - } let containsBlacklistedWord = false; moduleConfig['blacklisted_words'].forEach(word => { if (msg.content.toLowerCase().includes(word.toLowerCase())) containsBlacklistedWord = true; diff --git a/modules/moderation/module.json b/modules/moderation/module.json index 51d795b9..370731b9 100644 --- a/modules/moderation/module.json +++ b/modules/moderation/module.json @@ -2,8 +2,8 @@ "name": "moderation", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "commands-dir": "/commands", "events-dir": "/events", diff --git a/modules/nicknames/events/botReady.js b/modules/nicknames/events/botReady.js deleted file mode 100644 index aeb44e66..00000000 --- a/modules/nicknames/events/botReady.js +++ /dev/null @@ -1,7 +0,0 @@ -const {renameMember} = require('../renameMember'); - -module.exports.run = async function (client) { - for (const member of client.guild.members.cache.values()) { - await renameMember(client, member); - } -} \ No newline at end of file diff --git a/modules/nicknames/events/guildMemberUpdate.js b/modules/nicknames/events/guildMemberUpdate.js index e01f0b6e..0d93a14e 100644 --- a/modules/nicknames/events/guildMemberUpdate.js +++ b/modules/nicknames/events/guildMemberUpdate.js @@ -1,11 +1,23 @@ -const {renameMember} = require('../renameMember'); +const {persistExternalEditAsBase} = require('../persistExternalEditAsBase'); module.exports.run = async function (client, oldGuildMember, newGuildMember) { - if (!client.botReadyAt) return; if (newGuildMember.guild.id !== client.guild.id) return; - if (newGuildMember.nickname === oldGuildMember.nickname && newGuildMember.roles.cache.size === oldGuildMember.roles.cache.size) return; + if (newGuildMember.guild.ownerId === newGuildMember.id) return; + + const oldRoles = new Set(oldGuildMember.roles.cache.keys()); + const newRoles = new Set(newGuildMember.roles.cache.keys()); + const rolesChanged = oldRoles.size !== newRoles.size || + [...newRoles].some(r => !oldRoles.has(r)); + const nickChanged = oldGuildMember.nickname !== newGuildMember.nickname; + + if (!rolesChanged && !nickChanged) return; - await renameMember(client, newGuildMember); + const lastRendered = client.nicknameManager.getLastRendered(newGuildMember.id); + if (nickChanged && newGuildMember.nickname !== lastRendered) { + await persistExternalEditAsBase(client, newGuildMember); + } -}; \ No newline at end of file + client.nicknameManager.attachMember(newGuildMember); + client.nicknameManager.requestUpdate(newGuildMember.id); +}; diff --git a/modules/nicknames/module.json b/modules/nicknames/module.json index 6390e005..b806e971 100644 --- a/modules/nicknames/module.json +++ b/modules/nicknames/module.json @@ -9,6 +9,7 @@ "openSourceURL": "https://github.com/hfgd123/CustomDCBot/tree/main/modules/nicknames", "fa-icon": "fa-solid fa-user-pen", "events-dir": "/events", + "on-load-event": "onLoad.js", "models-dir": "/models", "config-example-files": [ "configs/config.json", diff --git a/modules/nicknames/onLoad.js b/modules/nicknames/onLoad.js new file mode 100644 index 00000000..035ea323 --- /dev/null +++ b/modules/nicknames/onLoad.js @@ -0,0 +1,53 @@ +const {persistExternalEditAsBase} = require('./persistExternalEditAsBase'); + +module.exports.onLoad = function (client) { + if (client.nicknamesProviderRegistered) return; + client.nicknamesProviderRegistered = true; + + client.nicknameManager.registerProvider('nicknames', 'nicknames', async (member) => { + const config = client.configurations?.['nicknames']?.['config']; + const roles = client.configurations?.['nicknames']?.['strings']; + if (!config || !roles) return null; + + const stored = await client.models['nicknames']['User'].findOne({where: {userID: member.id}}); + const baseName = config.forceDisplayname + ? member.user.displayName + : (stored?.nickname ?? member.user.displayName); + + const sortedRoles = [...member.roles.cache.values()].sort((a, b) => b.position - a.position); + let matched = null; + for (const r of sortedRoles) { + const m = roles.find(x => x.roleID === r.id); + if (m) { + matched = m; + break; + } + } + + const out = [{ + source: 'nicknames:base', + position: 'base', + value: baseName, + priority: 100 + }]; + if (matched?.prefix) out.push({ + source: 'nicknames:rolePrefix', + position: 'prefix', + value: matched.prefix, + priority: 10 + }); + if (matched?.suffix) out.push({ + source: 'nicknames:roleSuffix', + position: 'suffix', + value: matched.suffix, + priority: 10 + }); + return out; + }); + + client.nicknameManager.setBootstrapMemberHook(async (member) => { + + if (client.modules?.['nicknames']?.enabled === false) return; + await persistExternalEditAsBase(client, member); + }); +}; diff --git a/modules/nicknames/persistExternalEditAsBase.js b/modules/nicknames/persistExternalEditAsBase.js new file mode 100644 index 00000000..3f3c1e81 --- /dev/null +++ b/modules/nicknames/persistExternalEditAsBase.js @@ -0,0 +1,94 @@ +function reverseWrap(wrap, s) { + if (typeof wrap.value !== 'function') return null; + const sentinel = 'NICK_BASE'; + const wrapped = wrap.value(sentinel); + if (typeof wrapped !== 'string') return null; + const idx = wrapped.indexOf(sentinel); + if (idx === -1) return null; + const before = wrapped.slice(0, idx); + const after = wrapped.slice(idx + sentinel.length); + if (!s.startsWith(before) || !s.endsWith(after)) return null; + if (s.length < before.length + after.length) return null; + return s.slice(before.length, s.length - after.length); +} + +module.exports.persistExternalEditAsBase = async function (client, member) { + const moduleModel = client.models['nicknames']['User']; + const roles = client.configurations?.['nicknames']?.['strings'] || []; + const config = client.configurations?.['nicknames']?.['config'] || {}; + + let residue = member.nickname ?? member.user.displayName; + + if (client.nicknameManager) { + try { + await client.nicknameManager.pollProviders(member); + } catch (e) { + client.logger?.warn?.(`[nicknames] pollProviders failed for ${member.id}: ${e.message}`); + } + } + + const contributions = client.nicknameManager + ? client.nicknameManager.getContributions(member.id) + : []; + + const wraps = contributions + .filter(c => c.position === 'wrap') + .sort((a, b) => a.priority - b.priority); + for (const wrap of wraps) { + try { + const next = reverseWrap(wrap, residue); + if (next !== null) residue = next; + } catch (e) { + client.logger?.warn?.(`[nicknames] could not reverse wrap ${wrap.source} for ${member.id}: ${e.message}`); + } + } + + const prefixContribs = contributions.filter(c => c.position === 'prefix'); + const suffixContribs = contributions.filter(c => c.position === 'suffix'); + let previous; + do { + previous = residue; + for (const c of prefixContribs) { + if (c.match instanceof RegExp) { + const re = new RegExp('^(?:' + c.match.source + ')', c.match.flags.replace('g', '')); + const m = residue.match(re); + if (m && m[0].length > 0) residue = residue.slice(m[0].length); + } else if (typeof c.value === 'string' && c.value && residue.startsWith(c.value)) { + residue = residue.slice(c.value.length); + } + } + for (const c of suffixContribs) { + if (c.match instanceof RegExp) { + const re = new RegExp('(?:' + c.match.source + ')$', c.match.flags.replace('g', '')); + const m = residue.match(re); + if (m && m[0].length > 0) residue = residue.slice(0, residue.length - m[0].length); + } else if (typeof c.value === 'string' && c.value && residue.endsWith(c.value)) { + residue = residue.slice(0, -c.value.length); + } + } + for (const role of roles) { + if (role.prefix && residue.startsWith(role.prefix)) { + residue = residue.slice(role.prefix.length); + } + if (role.suffix && residue.endsWith(role.suffix)) { + residue = residue.slice(0, -role.suffix.length); + } + } + } while (residue !== previous); + + if (!residue) residue = member.user.displayName; + if (config.forceDisplayname) residue = member.user.displayName; + + const existing = await moduleModel.findOne({where: {userID: member.id}}); + if (existing) { + if (existing.nickname !== residue) { + existing.nickname = residue; + await existing.save(); + } + } else { + await moduleModel.create({ + userID: member.id, + nickname: residue + }); + } +}; diff --git a/modules/nicknames/renameMember.js b/modules/nicknames/renameMember.js deleted file mode 100644 index e4ae29bd..00000000 --- a/modules/nicknames/renameMember.js +++ /dev/null @@ -1,76 +0,0 @@ -const {truncate} = require('../../src/functions/helpers'); -const {localize} = require('../../src/functions/localize'); - -renameMember = async function (client, guildMember) { - const roles = client.configurations['nicknames']['strings']; - const config = client.configurations['nicknames']['config']; - const moduleModel = client.models['nicknames']['User']; - - let forceDisplayname = config['forceDisplayname']; - let rolePrefix = ''; - let roleSuffix = ''; - let userRoles = guildMember.roles.cache.sort((a, b) => b.position - a.position).map(r => r.id); - for (const userRole of userRoles) { - let role = roles.find(r => r.roleID === userRole); - if (role) { - rolePrefix = role.prefix; - roleSuffix = role.suffix; - break; - } - } - - - let user = await moduleModel.findOne({ - attributes: ['userID', 'nickname'], - where: { - userID: guildMember.id - } - }); - let memberName; - if (!guildMember.nickname || forceDisplayname) { - memberName = guildMember.user.displayName; - } else { - memberName = guildMember.nickname; - } - - for (const role of roles) { - if (memberName.startsWith(role.prefix)) { - memberName = memberName.replace(role.prefix, ''); - } - if (memberName.endsWith(role.suffix)) { - memberName = memberName.replace(role.suffix, ''); - } - } - - if (user) { - if (memberName !== user.nickname) { - user.nickname = memberName; - await user.save(); - } - } else { - await moduleModel.create({ - userID: guildMember.id, - nickname: memberName - }); - - } - - if (guildMember.displayName === truncate(rolePrefix + memberName, 32 - roleSuffix.length).concat(roleSuffix)) return; - if (guildMember.guild.ownerId === guildMember.id) { - client.logger.error('[nicknames] ' + localize('nicknames', 'owner-cannot-be-renamed', {u: guildMember.user.username})); - return; - } - if (guildMember.guild.ownerId === guildMember.id) { - client.logger.error('[nicknames] ' + localize('nicknames', 'owner-cannot-be-renamed', {u: guildMember.user.username})); - return; - } - try { - await guildMember.setNickname(truncate(rolePrefix + memberName, 32 - roleSuffix.length).concat(roleSuffix)); - } catch (e) { - client.logger.error('[nicknames] ' + localize('nicknames', 'nickname-error', { - u: guildMember.user.username, - e: e - })); - } -} -module.exports.renameMember = renameMember; \ No newline at end of file diff --git a/modules/ping-on-vc-join/module.json b/modules/ping-on-vc-join/module.json index 2d84496a..1cc7887d 100644 --- a/modules/ping-on-vc-join/module.json +++ b/modules/ping-on-vc-join/module.json @@ -2,8 +2,8 @@ "name": "ping-on-vc-join", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/ping-on-vc-join", "fa-icon": "fa-solid fa-volume-high", diff --git a/modules/ping-protection/commands/ping-protection.js b/modules/ping-protection/commands/ping-protection.js index 0d47c217..7d1539e1 100644 --- a/modules/ping-protection/commands/ping-protection.js +++ b/modules/ping-protection/commands/ping-protection.js @@ -1,11 +1,14 @@ -const { - generateHistoryResponse, - generateActionsResponse, - generateUserPanel +const { + generateHistoryResponse, + generateActionsResponse, + generateUserPanel } = require('../ping-protection'); -const { localize } = require('../../../src/functions/localize'); -const { truncate } = require('../../../src/functions/helpers'); -const { EmbedBuilder, MessageFlags } = require('discord.js'); +const {localize} = require('../../../src/functions/localize'); +const {truncate, safeSetFooter} = require('../../../src/functions/helpers'); +const { + EmbedBuilder, + MessageFlags +} = require('discord.js'); module.exports.run = async function (interaction) { const group = interaction.options.getSubcommandGroup(false); @@ -19,30 +22,31 @@ module.exports.run = async function (interaction) { // Handles subcommands module.exports.subcommands = { - 'user': { - 'history': async function (interaction) { - const user = interaction.options.getUser('user'); - const payload = await generateHistoryResponse(interaction.client, user.id, 1); - await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }); - }, - 'actions-history': async function (interaction) { - const user = interaction.options.getUser('user'); - const payload = await generateActionsResponse(interaction.client, user.id, 1); - await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }); - }, - 'panel': async function (interaction) { - const user = interaction.options.getUser('user'); - const payload = await generateUserPanel(interaction.client, user); - - await interaction.reply({ - ...payload, - flags: MessageFlags.Ephemeral - }); - } - }, - 'list': { - 'protected': async function (interaction) { - await listHandler(interaction, 'protected'); + 'user': { + 'history': async function (interaction) { + const user = interaction.options.getUser('user'); + const payload = await generateHistoryResponse(interaction.client, user.id, 1); + await interaction.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); + }, + 'actions-history': async function (interaction) { + const user = interaction.options.getUser('user'); + const payload = await generateActionsResponse(interaction.client, user.id, 1); + await interaction.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); + }, + 'panel': async function (interaction) { + const user = interaction.options.getUser('user'); + const payload = await generateUserPanel(interaction.client, user); + await interaction.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); + } }, 'list': { 'protected': async function (interaction) { diff --git a/modules/ping-protection/configs/configuration.json b/modules/ping-protection/configs/configuration.json index 37ab6434..1d773f59 100644 --- a/modules/ping-protection/configs/configuration.json +++ b/modules/ping-protection/configs/configuration.json @@ -121,7 +121,7 @@ "name": "enableAutomod", "category": "automod", "humanName": "Enable AutoMod", - "description": "If enabled, the bot will utilise Discord's native AutoMod to block the message with a ping of a protected user/role. Warning: AutoMod does not support whitelisted categories due to limitations in Discord's AutoMod system - instead, it will still block the message but not log it in the history." , + "description": "If enabled, the bot will utilise Discord's native AutoMod to block the message with a ping of a protected user/role. Warning: AutoMod does not support whitelisted categories due to limitations in Discord's AutoMod system - instead, it will still block the message but not log it in the history.", "type": "boolean", "default": true }, @@ -180,4 +180,4 @@ } } ] -} +} \ No newline at end of file diff --git a/modules/ping-protection/events/autoModerationActionExecution.js b/modules/ping-protection/events/autoModerationActionExecution.js index 1ab9e9a3..77884f36 100644 --- a/modules/ping-protection/events/autoModerationActionExecution.js +++ b/modules/ping-protection/events/autoModerationActionExecution.js @@ -1,4 +1,7 @@ -const { processPing, isWhitelistedChannel } = require('../ping-protection'); +const { + processPing, + isWhitelistedChannel +} = require('../ping-protection'); // Handles auto mod actions module.exports.run = async function (client, execution) { diff --git a/modules/ping-protection/events/interactionCreate.js b/modules/ping-protection/events/interactionCreate.js index f483e265..b54ea172 100644 --- a/modules/ping-protection/events/interactionCreate.js +++ b/modules/ping-protection/events/interactionCreate.js @@ -10,8 +10,11 @@ const { setDeletionCooldown, getDeletionTypeLocaleKey } = require('../ping-protection'); -const { localize } = require('../../../src/functions/localize'); -const { safeSetFooter, dateToDiscordTimestamp } = require('../../../src/functions/helpers.js'); +const {localize} = require('../../../src/functions/localize'); +const { + safeSetFooter, + dateToDiscordTimestamp +} = require('../../../src/functions/helpers.js'); const { MessageFlags, ModalBuilder, @@ -27,7 +30,7 @@ const { // Interaction handler module.exports.run = async function (client, interaction) { if (!client.botReadyAt) return; - const isAdmin = interaction.member?.permissions?.has('Administrator') + const isAdmin = interaction.member?.permissions?.has('Administrator'); if (interaction.isStringSelectMenu() && interaction.customId.startsWith('ping-protection_panel-menu_')) { if (!isAdmin) { @@ -100,17 +103,33 @@ module.exports.run = async function (client, interaction) { }); } + // Checks to ensure modal content fits Discord limits + let modalTitle = localize('ping-protection', 'modal-title'); + if (modalTitle.length > 45) { + modalTitle = localize('ping-protection', 'fallback-modal-title'); + } + + let modalLabel = localize('ping-protection', 'modal-label'); + if (modalLabel.length > 45) { + modalLabel = localize('ping-protection', 'fallback-modal-label'); + } + + let confirmationPhrase = localize('ping-protection', 'modal-phrase'); + if (confirmationPhrase.length > 100) { + confirmationPhrase = localize('ping-protection', 'fallback-modal-phrase'); + } + const modal = new ModalBuilder() .setCustomId(`ping-protection_del-confirm_${targetId}_${selection}`) - .setTitle(localize('ping-protection', 'modal-title')); + .setTitle(modalTitle); modal.addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('confirm') - .setLabel(localize('ping-protection', 'modal-label')) + .setLabel(modalLabel) .setStyle(TextInputStyle.Paragraph) - .setPlaceholder(localize('ping-protection', 'modal-phrase')) + .setPlaceholder(confirmationPhrase) .setRequired(true) ) ); @@ -130,7 +149,11 @@ module.exports.run = async function (client, interaction) { const targetId = parts[2]; const selection = parts.slice(3).join('_'); - const confirmPhrase = localize('ping-protection', 'modal-phrase'); + let confirmPhrase = localize('ping-protection', 'modal-phrase'); + if (confirmPhrase.length > 100) { + confirmPhrase = localize('ping-protection', 'fallback-modal-phrase'); + } + if (interaction.fields.getTextInputValue('confirm').trim() !== confirmPhrase) { return interaction.reply({ content: localize('ping-protection', 'modal-failed'), @@ -160,7 +183,7 @@ module.exports.run = async function (client, interaction) { const embed = new EmbedBuilder() .setTitle(localize('ping-protection', 'del-all-title')) .setDescription(localize('ping-protection', 'del-all-desc')) - .setColor('DarkRed') + .setColor('DarkRed'); safeSetFooter(embed, client); if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); @@ -230,7 +253,8 @@ module.exports.run = async function (client, interaction) { const targetUser = await client.users.fetch(targetId).catch(() => null); if (targetUser && interaction.message) { const payload = await generateUserPanel(client, targetUser); - await interaction.message.edit(payload).catch(() => {}); + await interaction.message.edit(payload).catch(() => { + }); } await btnInt.update({ @@ -249,7 +273,8 @@ module.exports.run = async function (client, interaction) { content: localize('ping-protection', 'err-del-time'), embeds: [], components: [] - }).catch(() => {}); + }).catch(() => { + }); } }); @@ -268,7 +293,8 @@ module.exports.run = async function (client, interaction) { const targetUser = await client.users.fetch(targetId).catch(() => null); if (targetUser && interaction.message) { const payload = await generateUserPanel(client, targetUser); - await interaction.message.edit(payload).catch(() => {}); + await interaction.message.edit(payload).catch(() => { + }); } return interaction.reply({ @@ -282,7 +308,7 @@ module.exports.run = async function (client, interaction) { // User panel dropdown and pages handler if (interaction.isButton() && interaction.customId.startsWith('ping-protection_')) { - + if (interaction.customId.startsWith('ping-protection_hist-page_')) { const parts = interaction.customId.split('_'); const userId = parts[2]; diff --git a/modules/ping-protection/models/DeletionCooldown.js b/modules/ping-protection/models/DeletionCooldown.js index d119af9f..85721c70 100644 --- a/modules/ping-protection/models/DeletionCooldown.js +++ b/modules/ping-protection/models/DeletionCooldown.js @@ -1,4 +1,7 @@ -const { DataTypes, Model } = require('sequelize'); +const { + DataTypes, + Model +} = require('sequelize'); module.exports = class PingProtectionDeletionCooldown extends Model { static init(sequelize) { diff --git a/modules/ping-protection/ping-protection.js b/modules/ping-protection/ping-protection.js index 2aa1cc57..01a5e120 100644 --- a/modules/ping-protection/ping-protection.js +++ b/modules/ping-protection/ping-protection.js @@ -3,10 +3,22 @@ * @module ping-protection * @author itskevinnn */ -const { Op } = require('sequelize'); -const { ActionRowBuilder, ButtonBuilder, EmbedBuilder, ButtonStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } = require('discord.js'); -const { embedType, embedTypeV2, formatDate, safeSetFooter } = require('../../src/functions/helpers'); -const { localize } = require('../../src/functions/localize'); +const {Op} = require('sequelize'); +const { + ActionRowBuilder, + ButtonBuilder, + EmbedBuilder, + ButtonStyle, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} = require('discord.js'); +const { + embedType, + embedTypeV2, + formatDate, + safeSetFooter +} = require('../../src/functions/helpers'); +const {localize} = require('../../src/functions/localize'); const recentPings = new Set(); // Data handling @@ -52,7 +64,7 @@ async function getPingCountInWindow(client, userId, days) { } // Fetches ping history -async function fetchPingHistory(client, userId, page = 1, limit = 5) { +async function fetchPingHistory(client, userId, page = 1, limit = 5) { const offset = (page - 1) * limit; const { count, @@ -72,7 +84,10 @@ async function fetchPingHistory(client, userId, page = 1, limit = 5) { // Fetches moderation history async function fetchModHistory(client, userId, page = 1, limit = 5) { if (!client.models['ping-protection'] || !client.models['ping-protection']['ModerationLog']) { - return { total: 0, history: [] }; + return { + total: 0, + history: [] + }; } try { @@ -95,7 +110,10 @@ async function fetchModHistory(client, userId, page = 1, limit = 5) { u: userId, e: e.message })); - return { total: 0, history: [] }; + return { + total: 0, + history: [] + }; } } @@ -205,7 +223,8 @@ async function getDeletionCooldown(client, userId) { const cooldown = await model.findByPk(userId); if (!cooldown) return null; if (new Date(cooldown.blockedUntil) <= new Date()) { - await cooldown.destroy().catch(() => {}); + await cooldown.destroy().catch(() => { + }); return null; } @@ -233,19 +252,19 @@ async function executeDataDeletion(client, userId, dataType) { if (['del_ping_history', 'del_all'].includes(dataType)) { await models.PingHistory.destroy({ - where: { userId } + where: {userId} }); } if (['del_moderation_history', 'del_all'].includes(dataType)) { await models.ModerationLog.destroy({ - where: { victimID: userId } + where: {victimID: userId} }); } if (dataType === 'del_all') { await models.LeaverData.destroy({ - where: { userId } + where: {userId} }); } } @@ -323,15 +342,15 @@ async function generateUserPanel(client, targetUser) { i: targetUser.id })) .setColor('Blue') - .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setThumbnail(targetUser.displayAvatarURL({dynamic: true})) .addFields([{ - name: localize('ping-protection', 'field-quick-history', { w: retentionWeeks }), + name: localize('ping-protection', 'field-quick-history', {w: retentionWeeks}), value: localize('ping-protection', 'field-quick-desc', { p: pingCount, m: modData.total }), inline: false - }]) + }]); safeSetFooter(embed, client); if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); @@ -364,7 +383,7 @@ async function generatePanelHistory(client, targetUser, page = 1) { if (leaverData) { const dateStr = formatDate(leaverData.leftAt); const warningKey = history.length > 0 ? 'leaver-warning-long' : 'leaver-warning-short'; - description += `⚠️ ${localize('ping-protection', warningKey, { d: dateStr })}\n\n`; + description += `⚠️ ${localize('ping-protection', warningKey, {d: dateStr})}\n\n`; } if (!isEnabled) { @@ -418,9 +437,9 @@ async function generatePanelHistory(client, targetUser, page = 1) { .setTitle(localize('ping-protection', 'embed-history-title', { u: targetUser.username })) - .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setThumbnail(targetUser.displayAvatarURL({dynamic: true})) .setDescription(description) - .setColor('Orange') + .setColor('Orange'); safeSetFooter(embed, client); if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); @@ -481,9 +500,9 @@ async function generatePanelActions(client, targetUser, page = 1) { .setTitle(localize('ping-protection', 'embed-actions-title', { u: targetUser.username })) - .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setThumbnail(targetUser.displayAvatarURL({dynamic: true})) .setDescription(description) - .setColor(isEnabled ? 'Red' : 'Grey') + .setColor(isEnabled ? 'Red' : 'Grey'); safeSetFooter(embed, client); if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); @@ -518,7 +537,7 @@ async function generatePanelDeletion(client, targetUser) { })) .setDescription(description) .setColor('DarkRed') - .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setThumbnail(targetUser.displayAvatarURL({dynamic: true})); safeSetFooter(embed, client); if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); @@ -581,7 +600,7 @@ async function syncNativeAutoMod(client) { e: error.message })); }); - + const rules = await guild.autoModerationRules.fetch(); const existingRule = rules.find(r => r.name === 'Ping Protection System'); @@ -957,30 +976,30 @@ async function executeAction(client, member, rule, reason, storageConfig, origin // Sends error message if action fails const sendErrorLog = async (error) => { - if (!originChannel) return; - - const errorEmbed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'punish-log-failed-title', { - u: member.user.tag - })) - .setDescription( - localize('ping-protection', 'punish-log-failed-desc', { - m: member.toString() - }) + - `\n${localize('ping-protection', 'punish-log-error', { - e: error.message - })}` - ) - .addFields({ - name: localize('ping-protection', 'punish-log-docs-title'), - value: localize('ping-protection', 'punish-log-docs-desc'), - inline: false - }) - .setColor('#ed4245') - + if (!originChannel) return; + + const errorEmbed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'punish-log-failed-title', { + u: member.user.tag + })) + .setDescription( + localize('ping-protection', 'punish-log-failed-desc', { + m: member.toString() + }) + + `\n${localize('ping-protection', 'punish-log-error', { + e: error.message + })}` + ) + .addFields({ + name: localize('ping-protection', 'punish-log-docs-title'), + value: localize('ping-protection', 'punish-log-docs-desc'), + inline: false + }) + .setColor('#ed4245'); + safeSetFooter(errorEmbed, client); if (!client.strings.disableFooterTimestamp) errorEmbed.setTimestamp(); - await originChannel.send({ embeds: [errorEmbed.toJSON()] }).catch((sendError) => { + await originChannel.send({embeds: [errorEmbed.toJSON()]}).catch((sendError) => { client.logger.warn(localize('ping-protection', 'log-punish-log-send-failed', { e: sendError.message })); diff --git a/modules/polls/commands/poll.js b/modules/polls/commands/poll.js index bc4c2c49..50728463 100644 --- a/modules/polls/commands/poll.js +++ b/modules/polls/commands/poll.js @@ -1,6 +1,6 @@ const {ChannelType} = require('discord.js'); const {truncate} = require('../../../src/functions/helpers'); -const durationParser = require('parse-duration'); +const durationParser = require('../../../src/functions/parseDuration'); const {localize} = require('../../../src/functions/localize'); const {createPoll, updateMessage} = require('../polls'); @@ -17,11 +17,16 @@ module.exports.subcommands = { for (let step = 1; step <= 10; step++) { if (interaction.options.getString(`option${step}`)) options.push(interaction.options.getString(`option${step}`)); } + let maxSelections = interaction.options.getInteger('max-selections'); + if (typeof maxSelections !== 'number') maxSelections = 1; + if (maxSelections > options.length) maxSelections = options.length; + if (maxSelections < 0) maxSelections = 1; await createPoll({ description: (interaction.options.getBoolean('public') ? '[PUBLIC]' : '') + interaction.options.getString('description', true), channel: interaction.options.getChannel('channel', true), endAt: endAt, - options + options, + maxSelections }, interaction.client); await interaction.editReply({ content: localize('polls', 'created-poll', {c: interaction.options.getChannel('channel').toString()}) @@ -122,6 +127,14 @@ module.exports.config = { name: 'public', required: false, description: localize('polls', 'command-poll-create-public-description') + }, + { + type: 'INTEGER', + name: 'max-selections', + required: false, + minValue: 0, + maxValue: 10, + description: localize('polls', 'command-poll-create-max-selections-description') } ] }, diff --git a/modules/polls/events/botReady.js b/modules/polls/events/botReady.js index 3fb16689..2636dc5c 100644 --- a/modules/polls/events/botReady.js +++ b/modules/polls/events/botReady.js @@ -9,4 +9,4 @@ module.exports.run = async (client) => { await updateMessage(await client.channels.fetch(poll.channelID), poll, poll.messageID); }); }); -}; \ No newline at end of file +}; diff --git a/modules/polls/events/interactionCreate.js b/modules/polls/events/interactionCreate.js index deb6cd8e..143e700b 100644 --- a/modules/polls/events/interactionCreate.js +++ b/modules/polls/events/interactionCreate.js @@ -17,16 +17,17 @@ module.exports.run = async (client, interaction) => { } if (interaction.isButton() && interaction.customId === 'polls-own-vote') { - let userVoteCat = null; + const userVoteCats = []; for (const id in poll.votes) { - if (poll.votes[id].includes(interaction.user.id)) userVoteCat = id; + if (poll.votes[id].includes(interaction.user.id)) userVoteCats.push(id); } - if (!userVoteCat) return interaction.reply({ + if (userVoteCats.length === 0) return interaction.reply({ content: '⚠️ ' + localize('polls', 'not-voted-yet'), ephemeral: true }); + const votedLabels = userVoteCats.map(c => poll.options[c - 1]).join(', '); return interaction.reply({ - content: localize('polls', 'you-voted', {o: poll.options[userVoteCat - 1]}) + (!expired ? '\n' + localize('polls', 'change-opinion') : ''), + content: localize('polls', 'you-voted', {o: votedLabels}) + (!expired ? '\n' + localize('polls', 'change-opinion') : ''), ephemeral: true, components: [ { @@ -68,6 +69,12 @@ module.exports.run = async (client, interaction) => { if (poll.expiresAt && new Date(poll.expiresAt).getTime() <= new Date().getTime()) return; if (interaction.isButton() && (interaction.customId || '').startsWith('polls-rem-vot-')) { + + /* + * Acknowledge before persisting and re-rendering the poll message (a REST edit), + * otherwise the reply can land after Discord's 3s window has expired the token. + */ + await interaction.deferReply({ephemeral: true}); const o = poll.votes; poll.votes = {}; for (const id in o) { @@ -76,24 +83,32 @@ module.exports.run = async (client, interaction) => { poll.votes = o; await poll.save(); await updateMessage(interaction.channel, poll, interaction.customId.replaceAll('polls-rem-vot-', '')); - return await interaction.reply({ - content: '✅ ' + localize('polls', 'removed-vote'), - ephemeral: true + return await interaction.editReply({ + content: '✅ ' + localize('polls', 'removed-vote') }); } if (interaction.isSelectMenu() && interaction.customId === 'polls-vote') { + + /* + * Acknowledge before persisting and re-rendering the poll message (a REST edit), + * otherwise the reply can land after Discord's 3s window has expired the token. + */ + await interaction.deferReply({ephemeral: true}); const o = poll.votes; poll.votes = {}; for (const id in o) { if (o[(parseInt(id)).toString()] && o[(parseInt(id)).toString()].includes(interaction.user.id)) o[(parseInt(id)).toString()].splice(o[(parseInt(id)).toString()].indexOf(interaction.user.id), 1); } - o[(parseInt(interaction.values[0]) + 1).toString()].push(interaction.user.id); + for (const value of interaction.values) { + const key = (parseInt(value) + 1).toString(); + if (!o[key]) o[key] = []; + if (!o[key].includes(interaction.user.id)) o[key].push(interaction.user.id); + } poll.votes = o; await poll.save(); await updateMessage(interaction.message.channel, poll, interaction.message.id); - await interaction.reply({ - content: localize('polls', 'voted-successfully'), - ephemeral: true + await interaction.editReply({ + content: localize('polls', 'voted-successfully') }); } }; \ No newline at end of file diff --git a/modules/polls/migrations/polls_Poll__V1.js b/modules/polls/migrations/polls_Poll__V1.js new file mode 100644 index 00000000..e387d91f --- /dev/null +++ b/modules/polls/migrations/polls_Poll__V1.js @@ -0,0 +1,35 @@ +const {DataTypes} = require('sequelize'); + +const TABLE = 'polls_Poll'; + +module.exports = { + tables: [TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (!description.maxSelections) { + await queryInterface.addColumn(TABLE, 'maxSelections', { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1 + }, {transaction}); + } + }); + }, + down: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.maxSelections) await queryInterface.removeColumn(TABLE, 'maxSelections', {transaction}); + }); + } +}; \ No newline at end of file diff --git a/modules/polls/models/Poll.js b/modules/polls/models/Poll.js index cc6e73de..a5aec0f4 100644 --- a/modules/polls/models/Poll.js +++ b/modules/polls/models/Poll.js @@ -11,7 +11,12 @@ module.exports = class Poll extends Model { options: DataTypes.JSON, votes: DataTypes.JSON, // {1: ["userIDHere"], 2: ["as"] } expiresAt: DataTypes.DATE, - channelID: DataTypes.STRING + channelID: DataTypes.STRING, + maxSelections: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1 + } // Max selections per voter. 0 = unlimited. }, { tableName: 'polls_Poll', timestamps: true, diff --git a/modules/polls/module.json b/modules/polls/module.json index 40e924e6..64ef9ddb 100644 --- a/modules/polls/module.json +++ b/modules/polls/module.json @@ -2,8 +2,8 @@ "name": "polls", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "description": "Simple module to create fresh polls on your server! Supports anonymous polls and more.", "events-dir": "/events", diff --git a/modules/polls/polls.js b/modules/polls/polls.js index b57c8198..e7b481a2 100644 --- a/modules/polls/polls.js +++ b/modules/polls/polls.js @@ -31,7 +31,8 @@ async function createPoll(data, client) { options: data.options, channelID: data.channel.id, expiresAt: data.endAt, - votes: votes + votes: votes, + maxSelections: typeof data.maxSelections === 'number' ? data.maxSelections : 1 }); if (data.endAt) { @@ -76,6 +77,12 @@ async function updateMessage(channel, data, mID = null) { embed.addField(strings.embed.options, s); embed.addField(strings.embed.liveView, p); embed.addField(strings.embed.visibility, localize('polls', `poll-${data.description.startsWith('[PUBLIC]') ? 'public' : 'private'}`)); + const optionCount = Object.keys(data.options).length; + const rawMaxSelections = typeof data.maxSelections === 'number' ? data.maxSelections : 1; + const effectiveMax = (rawMaxSelections === 0 || rawMaxSelections > optionCount) ? optionCount : rawMaxSelections; + if (effectiveMax > 1) { + embed.addField(localize('polls', 'max-selections-field'), rawMaxSelections === 0 ? localize('polls', 'max-selections-unlimited') : localize('polls', 'max-selections-limit', {n: effectiveMax})); + } const options = []; for (const vId in data.options) { @@ -108,7 +115,7 @@ async function updateMessage(channel, data, mID = null) { disabled: expired, customId: 'polls-vote', min_values: 1, - max_values: 1, + max_values: effectiveMax, placeholder: localize('polls', 'vote'), options }] diff --git a/modules/quiz/commands/quiz.js b/modules/quiz/commands/quiz.js index 54773b69..ecf23f10 100644 --- a/modules/quiz/commands/quiz.js +++ b/modules/quiz/commands/quiz.js @@ -1,5 +1,5 @@ const {ChannelType, ComponentType, MessageEmbed} = require('discord.js'); -const durationParser = require('parse-duration'); +const durationParser = require('../../../src/functions/parseDuration'); const { formatDate, shuffleArray, @@ -68,13 +68,20 @@ async function create(interaction) { }); if (interaction.options.getString('duration')) endAt = new Date(new Date().getTime() + durationParser(interaction.options.getString('duration'))); + const imageURL = interaction.options.getString('image'); + if (imageURL && !/^https?:\/\//i.test(imageURL)) return i.update({ + content: localize('quiz', 'invalid-image-url'), + components: [] + }); await createQuiz({ description: interaction.options.getString('description', true), channel: interaction.options.getChannel('channel', true), endAt, options, canChangeVote: interaction.options.getBoolean('canchange') || false, - type: interaction.options.getSubcommand() === 'create-bool' ? 'bool' : 'normal' + type: interaction.options.getSubcommand() === 'create-bool' ? 'bool' : 'normal', + imageURL: imageURL || null, + headline: interaction.options.getString('headline') || null }, interaction.client); i.update({ content: localize('quiz', 'created', {c: interaction.options.getChannel('channel').toString()}), @@ -129,6 +136,8 @@ module.exports.subcommands = { quiz.endAt = new Date(new Date().getTime() + durationParser(quiz.duration)); quiz.canChangeVote = false; quiz.private = true; + quiz.imageURL = quiz.imageURL || null; + quiz.headline = quiz.headline || null; createQuiz(quiz, interaction.client, interaction); interaction.client.models['quiz']['QuizUser'].update(updatedUser, {where: {userID: interaction.user.id}}); @@ -226,6 +235,18 @@ module.exports.config = { name: 'canchange', required: false, description: localize('quiz', 'cmd-create-canchange-description') + }, + { + type: 'STRING', + name: 'image', + required: false, + description: localize('quiz', 'cmd-create-image-description') + }, + { + type: 'STRING', + name: 'headline', + required: false, + description: localize('quiz', 'cmd-create-headline-description') }] }, { @@ -256,6 +277,18 @@ module.exports.config = { name: 'duration', required: false, description: localize('quiz', 'cmd-create-endAt-description') + }, + { + type: 'STRING', + name: 'image', + required: false, + description: localize('quiz', 'cmd-create-image-description') + }, + { + type: 'STRING', + name: 'headline', + required: false, + description: localize('quiz', 'cmd-create-headline-description') }] }, { diff --git a/modules/quiz/configs/quizList.json b/modules/quiz/configs/quizList.json index f99d71a6..d08578d9 100644 --- a/modules/quiz/configs/quizList.json +++ b/modules/quiz/configs/quizList.json @@ -33,6 +33,22 @@ "description": "Wrong answers", "type": "array", "content": "string" + }, + { + "name": "headline", + "humanName": "Headline (optional)", + "default": "", + "description": "Optional embed title shown above the question. Leave empty to use the default quiz title.", + "type": "string", + "allowNull": true + }, + { + "name": "imageURL", + "humanName": "Image (optional)", + "default": "", + "description": "Optional image displayed above the answer choices (e.g. movie scene, visual hint). Upload via the dashboard or paste an http(s) URL.", + "type": "imgURL", + "allowNull": true } ] } \ No newline at end of file diff --git a/modules/quiz/migrations/quiz_QuizList__V1.js b/modules/quiz/migrations/quiz_QuizList__V1.js new file mode 100644 index 00000000..7cf4e16a --- /dev/null +++ b/modules/quiz/migrations/quiz_QuizList__V1.js @@ -0,0 +1,45 @@ +const {DataTypes} = require('sequelize'); + +/* + * The model is registered as `QuizList` (legacy marker key `quiz_QuizList`) but its + * `tableName` is `quiz_Quiz`. Reference the table by its real name in the DDL. + */ +const TABLE = 'quiz_Quiz'; + +module.exports = { + tables: [TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (!description.imageURL) { + await queryInterface.addColumn(TABLE, 'imageURL', { + type: DataTypes.STRING, + allowNull: true + }, {transaction}); + } + if (!description.headline) { + await queryInterface.addColumn(TABLE, 'headline', { + type: DataTypes.STRING, + allowNull: true + }, {transaction}); + } + }); + }, + down: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.headline) await queryInterface.removeColumn(TABLE, 'headline', {transaction}); + if (description.imageURL) await queryInterface.removeColumn(TABLE, 'imageURL', {transaction}); + }); + } +}; \ No newline at end of file diff --git a/modules/quiz/models/Quiz.js b/modules/quiz/models/Quiz.js index 513e4fe2..1e90d84e 100644 --- a/modules/quiz/models/Quiz.js +++ b/modules/quiz/models/Quiz.js @@ -14,7 +14,15 @@ module.exports = class QuizList extends Model { channelID: DataTypes.STRING, canChangeVote: DataTypes.BOOLEAN, private: DataTypes.BOOLEAN, - type: DataTypes.STRING // normal, bool + type: DataTypes.STRING, // normal, bool + imageURL: { + type: DataTypes.STRING, + allowNull: true + }, + headline: { + type: DataTypes.STRING, + allowNull: true + } }, { tableName: 'quiz_Quiz', timestamps: true, diff --git a/modules/quiz/quizUtil.js b/modules/quiz/quizUtil.js index 01644d30..5583e83d 100644 --- a/modules/quiz/quizUtil.js +++ b/modules/quiz/quizUtil.js @@ -45,7 +45,9 @@ async function createQuiz(data, client, interaction) { votes, canChangeVote: data.canChangeVote, private: data.private || false, - type: data.type + type: data.type, + imageURL: data.imageURL || null, + headline: data.headline || null }); if (!data.private && data.endAt) { @@ -73,9 +75,10 @@ async function updateMessage(channel, data, mID = null, interaction = null) { if (mID && !interaction) m = await channel.messages.fetch(mID).catch(() => { }); const embed = new MessageEmbed() - .setTitle(strings.embed.title) + .setTitle(data.headline || strings.embed.title) .setColor(parseEmbedColor(strings.embed.color)) .setDescription(data.description); + if (data.imageURL) embed.setImage(data.imageURL); let allVotes = 0; const expired = (data.expiresAt || data.endAt) ? data.expiresAt <= Date.now() || data.endAt <= Date.now() : false; diff --git a/modules/reaction-roles/events/messageReactionAdd.js b/modules/reaction-roles/events/messageReactionAdd.js new file mode 100644 index 00000000..76841906 --- /dev/null +++ b/modules/reaction-roles/events/messageReactionAdd.js @@ -0,0 +1,18 @@ +module.exports.run = async function (client, reaction, user) { + if (!client.botReadyAt) return; + if (reaction.partial) reaction = await reaction.fetch(); + if (reaction.message.guildId !== client.guild.id) return; + if (user.id === client.user.id) return; + + const moduleMessages = client.configurations['reaction-roles']['messages']; + const config = moduleMessages.find(f => f.messageID === reaction.message.id); + if (!config) return; + const roleContent = config.reactions[reaction['_emoji'].toString()]; + if (!roleContent) return; + const member = await reaction.message.guild.members.fetch(user.id); + await member.roles.add(roleContent.split(',')); + reaction.message.react(reaction['_emoji'].toString()).then(() => { + }).catch((e) => console.error); +}; + +module.exports.allowPartial = true; \ No newline at end of file diff --git a/modules/reaction-roles/events/messageReactionRemove.js b/modules/reaction-roles/events/messageReactionRemove.js new file mode 100644 index 00000000..1d16447b --- /dev/null +++ b/modules/reaction-roles/events/messageReactionRemove.js @@ -0,0 +1,15 @@ +module.exports.run = async function (client, reaction, user) { + if (!client.botReadyAt) return; + if (reaction.partial) reaction = await reaction.fetch(); + if (reaction.message.guildId !== client.guild.id) return; + + const moduleMessages = client.configurations['reaction-roles']['messages']; + const config = moduleMessages.find(f => f.messageID === reaction.message.id); + if (!config) return; + const roleContent = config.reactions[reaction['_emoji'].toString()]; + if (!roleContent) return; + const member = await reaction.message.guild.members.fetch(user.id); + await member.roles.remove(roleContent.split(',')); +}; + +module.exports.allowPartial = true; \ No newline at end of file diff --git a/modules/reaction-roles/messages.json b/modules/reaction-roles/messages.json new file mode 100644 index 00000000..4cdcdf68 --- /dev/null +++ b/modules/reaction-roles/messages.json @@ -0,0 +1,34 @@ +{ + "filename": "messages.json", + "description": "Add the messages you want to add reaction roles too.", + "humanName": "Messages", + "informationBanner": { + "button": { + "url": "https://scootk.it/login-as-bot", + "text": "Open Login-As-Bot" + }, + "en": "You can have a way better user experience for your members using Button-Roles, Self-Role-Elements and other SCNX features in Login-As-Bot.", + "de": "Du kannst eine wesentlich Bessere Nutzererfahrung für deine Mitglieder erstellen, indem du Button-Rollen, Selbst-Rollen-Erfahrungen und mehr SCNX Funktionen von Als-Bot-Anmelden verwenden." + }, + "configElements": true, + "content": [ + { + "name": "messageID", + "type": "string", + "default": "", + "description": "This is the ID of the message that this configuration element should apply to", + "humanName": "Message-ID" + }, + { + "name": "reactions", + "type": "keyed", + "content": { + "key": "emoji", + "value": "string" + }, + "default": {}, + "humanName": "Reactions", + "description": "First-Value: Reaction value, Second value: Role-ID(s), seperated with \",\". The bot will only add a reaction to these messages AFTER at least one user reacted with them." + } + ] +} \ No newline at end of file diff --git a/modules/reaction-roles/module.json b/modules/reaction-roles/module.json new file mode 100644 index 00000000..334c15f3 --- /dev/null +++ b/modules/reaction-roles/module.json @@ -0,0 +1,18 @@ +{ + "name": "reaction-roles", + "author": { + "scnxOrgID": "1", + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" + }, + "events-dir": "/events", + "fa-icon": "fas fa-smile", + "config-example-files": [ + "messages.json" + ], + "tags": [ + "bot" + ], + "humanReadableName": "Reaction Roles", + "description": "Let users assign roles to themselves the good old way - by adding and removing a reaction." +} diff --git a/modules/reminders/commands/reminder.js b/modules/reminders/commands/reminder.js index 3462dd3e..f21b91ff 100644 --- a/modules/reminders/commands/reminder.js +++ b/modules/reminders/commands/reminder.js @@ -1,5 +1,5 @@ const {localize} = require('../../../src/functions/localize'); -const durationParser = require('parse-duration'); +const durationParser = require('../../../src/functions/parseDuration'); const {planReminder} = require('../reminders'); const {formatDate} = require('../../../src/functions/helpers'); diff --git a/modules/reminders/module.json b/modules/reminders/module.json index 38187286..73c5761b 100644 --- a/modules/reminders/module.json +++ b/modules/reminders/module.json @@ -3,8 +3,8 @@ "humanReadableName": "Reminders", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "description": "Let users set reminders for themselves - either via DMs or Channels", "commands-dir": "/commands", diff --git a/modules/rock-paper-scissors/commands/rock-paper-scissors.js b/modules/rock-paper-scissors/commands/rock-paper-scissors.js index 127738c1..a338f883 100644 --- a/modules/rock-paper-scissors/commands/rock-paper-scissors.js +++ b/modules/rock-paper-scissors/commands/rock-paper-scissors.js @@ -157,6 +157,12 @@ function mentionUsers(game) { return mention || null; } +module.exports.findWinner = findWinner; +module.exports.mentionUsers = mentionUsers; +module.exports.resetGame = resetGame; +module.exports._rpsgames = rpsgames; +module.exports._moves = moves; + module.exports.run = async function (interaction) { const member = interaction.options.getMember('user'); diff --git a/modules/staff-management-system/commands/duty.js b/modules/staff-management-system/commands/duty.js index 45e4a425..d2897d3f 100644 --- a/modules/staff-management-system/commands/duty.js +++ b/modules/staff-management-system/commands/duty.js @@ -1025,8 +1025,16 @@ async function handleDutyAdminVoidAll(client, interaction) { const permCheck = checkDutyAdminPermission(client, interaction); if (permCheck) return permCheck; + let confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + if (confirmPhrase.length > 100) { + confirmPhrase = localize('staff-management-system', 'fallback-conf-phrase'); + } + let delModalLabel = localize('staff-management-system', 'mod-del-lbl'); + if (delModalLabel.length > 45) { + delModalLabel = localize('staff-management-system', 'fallback-del-lbl'); + } + const targetUserId = interaction.customId.split('_')[2]; - const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); const modal = new ModalBuilder() .setCustomId(`duty-mgmt_admin-voidall-submit_${targetUserId}`) .setTitle(localize('staff-management-system', 'mod-v-all-title')); @@ -1035,8 +1043,8 @@ async function handleDutyAdminVoidAll(client, interaction) { new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('confirm') - .setLabel(localize('staff-management-system', 'mod-del-lbl')) - .setStyle(TextInputStyle.Short) + .setLabel(delModalLabel) + .setStyle(TextInputStyle.Paragraph) .setPlaceholder(confirmPhrase) .setRequired(true) ) @@ -1049,7 +1057,10 @@ async function handleDutyAdminVoidAllSubmit(client, interaction) { if (permCheck) return permCheck; const targetUserId = interaction.customId.split('_')[2]; - const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + let confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + if (confirmPhrase.length > 100) { + confirmPhrase = localize('staff-management-system', 'fallback-conf-phrase'); + } if (interaction.fields.getTextInputValue('confirm').trim() !== confirmPhrase) { return interaction.reply({ @@ -1544,4 +1555,12 @@ module.exports.buttonHandlers = { handleDutyAdminVoidAll, handleDutyAdminVoidAllSubmit, handleDutyAdminAddTimeSubmit +}; + +// Exported for unit testing of the pure duty helpers. +module.exports._test = { + getLookbackDate, + canUseDutyAdmin, + applyBreakElapsedToShift, + getQuotaForMember }; \ No newline at end of file diff --git a/modules/staff-management-system/commands/staff-management.js b/modules/staff-management-system/commands/staff-management.js index e667ee19..f064a46b 100644 --- a/modules/staff-management-system/commands/staff-management.js +++ b/modules/staff-management-system/commands/staff-management.js @@ -138,10 +138,6 @@ async function handleProfileView(client, interaction, targetUser) { }; let embedTemplate = config.profileEmbedMessage; - if (typeof embedTemplate === 'string') { - try { embedTemplate = JSON.parse(embedTemplate); } catch (e) {} - } - let msgOpts = await embedTypeV2(embedTemplate, placeholders); if (!msgOpts) { diff --git a/modules/staff-management-system/configs/reviews.json b/modules/staff-management-system/configs/reviews.json index 60655504..a31bb243 100644 --- a/modules/staff-management-system/configs/reviews.json +++ b/modules/staff-management-system/configs/reviews.json @@ -21,7 +21,8 @@ "humanName": "Enable Reviews System", "description": "Enabling this unlocks the staff review system, allowing users to submit ratings with feedback for staff members.", "type": "boolean", - "default": true + "default": true, + "elementToggle": true }, { "name": "reviewLogChannel", diff --git a/modules/staff-management-system/events/botReady.js b/modules/staff-management-system/events/botReady.js index 6d2405a6..4b0747ae 100644 --- a/modules/staff-management-system/events/botReady.js +++ b/modules/staff-management-system/events/botReady.js @@ -1,69 +1,11 @@ const schedule = require('node-schedule'); const { localize } = require('../../../src/functions/localize'); const { Op } = require('sequelize'); -const { - migrationStart, - migrationEnd -} = require('../../../main'); const {scheduleStatusExpiry} = require('../commands/staff-status.js'); const { initActivityCheckAutomation } = require('../staff-management'); const suspension_check_job = 'staff-management-checks'; module.exports.run = async (client) => { - const dbVersion = await client.models['DatabaseSchemeVersion'].findOne({ - where: { - model: 'staff-management-system_ActivityCheck', - version: 'V1' - } - }); - - if (!dbVersion) { - migrationStart(); - try { - client.logger.info('[staff-management-system] Running V1 migration (adding initiatorId and isAutomated)...'); - - const data = await client.models['staff-management-system']['ActivityCheck'].findAll({ - attributes: [ - 'id', - 'messageId', - 'channelId', - 'endTime', - 'targetRoles', - 'respondedUsers', - 'status', - 'createdAt', - 'updatedAt' - ] - }); - - await client.models['staff-management-system']['ActivityCheck'].sync({ force: true }); - - for (const row of data) { - await client.models['staff-management-system']['ActivityCheck'].create({ - id: row.id, - messageId: row.messageId, - channelId: row.channelId, - endTime: row.endTime, - targetRoles: row.targetRoles, - respondedUsers: row.respondedUsers, - status: row.status, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - initiatorId: null, - isAutomated: false - }); - } - - client.logger.info('[staff-management-system] V1 migration complete.'); - await client.models['DatabaseSchemeVersion'].create({ - model: 'staff-management-system_ActivityCheck', - version: 'V1' - }); - } finally { - migrationEnd(); - } - } - const guild = client.guilds.cache.get(client.guildID); try { const LoaRequest = client.models['staff-management-system']['LoaRequest']; @@ -140,7 +82,6 @@ async function checkExpiredSuspensions(client, guild) { try { let rolesToRestore = []; - if (profile?.suspendedRoles) { try { const parsed = JSON.parse(profile.suspendedRoles); @@ -151,7 +92,7 @@ async function checkExpiredSuspensions(client, guild) { ); } } - + if (member) { if (rolesToRestore.length > 0) { await member.roles.add(rolesToRestore).catch(e => { diff --git a/modules/staff-management-system/events/interactionCreate.js b/modules/staff-management-system/events/interactionCreate.js index 804f7400..cea2316e 100644 --- a/modules/staff-management-system/events/interactionCreate.js +++ b/modules/staff-management-system/events/interactionCreate.js @@ -163,15 +163,24 @@ module.exports.run = async (client, interaction) => { return interaction.update(payload); } - const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + let confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + if (confirmPhrase.length > 100) { + confirmPhrase = localize('staff-management-system', 'fallback-conf-phrase'); + } + let delModalLabel = localize('staff-management-system', 'mod-del-lbl'); + if (delModalLabel.length > 45) { + delModalLabel = localize('staff-management-system', 'fallback-del-lbl'); + } + const delModalTitle = localize('staff-management-system', 'mod-del-title'); + const modal = new ModalBuilder() .setCustomId(`staff-mgmt_del-confirm_${targetId}_${selection}`) - .setTitle(localize('staff-management-system', 'mod-del-title')); + .setTitle(delModalTitle); modal.addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('confirm') - .setLabel(localize('staff-management-system', 'mod-del-lbl')) + .setLabel(delModalLabel) .setStyle(TextInputStyle.Paragraph) .setPlaceholder(confirmPhrase) .setRequired(true) @@ -195,7 +204,10 @@ module.exports.run = async (client, interaction) => { const targetId = parts[2]; const selection = parts.slice(3).join('_'); - const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + let confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + if (confirmPhrase.length > 100) { + confirmPhrase = localize('staff-management-system', 'fallback-conf-phrase'); + } if (interaction.fields.getTextInputValue('confirm').trim() !== confirmPhrase) { return interaction.editReply({ diff --git a/modules/staff-management-system/migrations/staff-management-system_ActivityCheck__V1.js b/modules/staff-management-system/migrations/staff-management-system_ActivityCheck__V1.js new file mode 100644 index 00000000..4a899f49 --- /dev/null +++ b/modules/staff-management-system/migrations/staff-management-system_ActivityCheck__V1.js @@ -0,0 +1,41 @@ +const {DataTypes} = require('sequelize'); + +const TABLE = 'staff_management_activity_checks'; + +module.exports = { + tables: [TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (!description.initiatorId) { + await queryInterface.addColumn(TABLE, 'initiatorId', { + type: DataTypes.STRING, + allowNull: true + }, {transaction}); + } + if (!description.isAutomated) { + await queryInterface.addColumn(TABLE, 'isAutomated', { + type: DataTypes.BOOLEAN, + defaultValue: false + }, {transaction}); + } + }); + }, + down: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.isAutomated) await queryInterface.removeColumn(TABLE, 'isAutomated', {transaction}); + if (description.initiatorId) await queryInterface.removeColumn(TABLE, 'initiatorId', {transaction}); + }); + } +}; \ No newline at end of file diff --git a/modules/staff-management-system/module.json b/modules/staff-management-system/module.json index 0d5c0613..3af774fb 100644 --- a/modules/staff-management-system/module.json +++ b/modules/staff-management-system/module.json @@ -5,7 +5,7 @@ "name": "Kevin", "link": "https://github.com/Kevinking500" }, - "fa-icon": "far fa-gear looks", + "fa-icon": "fa-duotone fa-gear", "openSourceURL": "https://github.com/Kevinking500/CustomDCBot/tree/main/modules/staff-management-system", "commands-dir": "/commands", "events-dir": "/events", @@ -21,8 +21,8 @@ "configs/activity-checks.json" ], "tags": [ - "moderation" + "administration" ], "humanReadableName": "Staff Management System", "description": "A powerful, highly customizable staff management system designed to track activity, moderate personnel, and maintain detailed staff records seamlessly." -} +} \ No newline at end of file diff --git a/modules/staff-management-system/staff-management.js b/modules/staff-management-system/staff-management.js index 3538e88f..18483c53 100644 --- a/modules/staff-management-system/staff-management.js +++ b/modules/staff-management-system/staff-management.js @@ -6,7 +6,7 @@ const { ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } = require('discord.js'); const { Op } = require('sequelize'); const schedule = require('node-schedule'); -const { embedTypeV2, safeSetFooter, dateToDiscordTimestamp } = require('../../src/functions/helpers'); +const {embedTypeV2, safeSetFooter, dateToDiscordTimestamp} = require('../../src/functions/helpers'); const { localize } = require('../../src/functions/localize'); // --- Local helpers --- @@ -119,17 +119,18 @@ async function issueInfraction(client, interaction, targetMember, type, reason, content: localize('staff-management-system', 'err-feat-disabled', {feature: 'Infractions'}) }); + const generalConfig = getConfig(client, 'configuration'); + const canInfract = checkStaffPermissions(interaction.member, generalConfig, 'supervisor'); + if (!canInfract) return interaction.editReply({ + content: localize('staff-management-system', 'err-gen-no-perm') + }); + if (targetMember.id === interaction.user.id) { return interaction.editReply({ content: localize('staff-management-system', 'err-self-infract') }); } - const canInfract = checkStaffPermissions(interaction.member, config, 'staff'); - if (!canInfract) return interaction.editReply({ - content: localize('staff-management-system', 'err-gen-no-perm') - }); - if (type.toLowerCase() === 'suspension') { return interaction.editReply({ content: localize('staff-management-system', 'err-use-susp') @@ -179,14 +180,6 @@ async function issueInfraction(client, interaction, targetMember, type, reason, const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); if (channel) { let template = config.infractionMessage; - if (typeof template === 'string') { - try { - template = JSON.parse(template); - } catch (e) { - } - } else if (typeof template === 'object') { - template = JSON.parse(JSON.stringify(template)); - } if (template && template.embeds && !template._schema) template._schema = 'v3'; let msgOpts = await embedTypeV2(template, placeholders); @@ -205,15 +198,6 @@ async function issueInfraction(client, interaction, targetMember, type, reason, if (config.dmInfractedUser && config.infractionDmMessage) { let dmTemplate = config.infractionDmMessage; - if (typeof dmTemplate === 'string') { - try { - dmTemplate = JSON.parse(dmTemplate); - } catch (e) { - } - } else if (typeof dmTemplate === 'object') { - dmTemplate = JSON.parse(JSON.stringify(dmTemplate)); - } - if (dmTemplate && dmTemplate.embeds && !dmTemplate._schema) dmTemplate._schema = 'v3'; const dmOpts = await embedTypeV2(dmTemplate, placeholders); if (dmOpts?.content?.trim() === '') delete dmOpts.content; @@ -257,17 +241,18 @@ async function issueSuspension(client, interaction, targetMember, durationInput, }) }); + const generalConfig = getConfig(client, 'configuration'); + const canSuspend = checkStaffPermissions(interaction.member, generalConfig, 'supervisor'); + if (!canSuspend) return interaction.editReply({ + content: localize('staff-management-system', 'err-gen-no-perm') + }); + if (targetMember.id === interaction.user.id) { return interaction.editReply({ content: localize('staff-management-system', 'err-self-infract') }); } - const canSuspend = checkStaffPermissions(interaction.member, config, 'staff'); - if (!canSuspend) return interaction.editReply({ - content: localize('staff-management-system', 'err-gen-no-perm') - }); - const durationDays = parseDurationToDays(durationInput); if (!durationDays) return interaction.editReply({ @@ -329,14 +314,6 @@ async function issueSuspension(client, interaction, targetMember, durationInput, const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); if (channel) { let template = config.suspensionMessage; - if (typeof template === 'string') { - try { - template = JSON.parse(template); - } catch (e) { - } - } else if (typeof template === 'object') { - template = JSON.parse(JSON.stringify(template)); - } if (template && template.embeds && !template._schema) template._schema = 'v3'; let msgOpts = await embedTypeV2(template, placeholders); @@ -355,14 +332,6 @@ async function issueSuspension(client, interaction, targetMember, durationInput, if (config.dmInfractedUser && config.suspensionDmMessage) { let dmTemplate = config.suspensionDmMessage; - if (typeof dmTemplate === 'string') { - try { - dmTemplate = JSON.parse(dmTemplate); - } catch (e) { - } - } else if (typeof dmTemplate === 'object') { - dmTemplate = JSON.parse(JSON.stringify(dmTemplate)); - } if (dmTemplate && dmTemplate.embeds && !dmTemplate._schema) dmTemplate._schema = 'v3'; const dmOpts = await embedTypeV2(dmTemplate, placeholders); @@ -455,7 +424,7 @@ async function voidInfraction(client, interaction, reference) { const rolesToRestore = JSON.parse(profile.suspendedRoles || '[]'); if (rolesToRestore.length > 0) await member.roles.add(rolesToRestore); if (config.suspensionRole) await member.roles.remove(config.suspensionRole); - await profile.update({ isSuspended: false, suspendedRoles: '[]' }); + await profile.update({ isSuspended: false, suspendedRoles: JSON.stringify([]) }); } catch (e) { return interaction.editReply({ content: localize('staff-management-system', 'succ-void-fail', {caseId: record.caseId}) @@ -543,6 +512,12 @@ async function promoteUser(client, interaction, targetMember, newRole, reason) { content: localize('staff-management-system', 'err-feat-disabled', {feature: 'Promotions'}) }); + const generalConfig = getConfig(client, 'configuration'); + const canPromote = checkStaffPermissions(interaction.member, generalConfig, 'supervisor'); + if (!canPromote) return interaction.editReply({ + content: localize('staff-management-system', 'err-gen-no-perm') + }); + if (targetMember.id === interaction.user.id) { return interaction.editReply({ content: localize('staff-management-system', 'err-self-promo') @@ -605,8 +580,9 @@ async function promoteUser(client, interaction, targetMember, newRole, reason) { if (typeof embedTemplate === 'string') { try { embedTemplate = JSON.parse(embedTemplate); + } catch (e) { } - catch (e) {} } else if (typeof embedTemplate === 'object') { + } else if (typeof embedTemplate === 'object') { embedTemplate = JSON.parse(JSON.stringify(embedTemplate)); } @@ -635,14 +611,6 @@ async function promoteUser(client, interaction, targetMember, newRole, reason) { if (config.dmPromotedUser && config.promotionDmMessage) { let dmTemplate = config.promotionDmMessage; - if (typeof dmTemplate === 'string') { - try { - dmTemplate = JSON.parse(dmTemplate); - } catch (e) { - } - } else if (typeof dmTemplate === 'object') { - dmTemplate = JSON.parse(JSON.stringify(dmTemplate)); - } if (dmTemplate && dmTemplate.embeds && !dmTemplate._schema) dmTemplate._schema = 'v3'; const dmOpts = await embedTypeV2(dmTemplate, placeholders); @@ -1411,31 +1379,29 @@ async function startActivityCheck(client, interactionOrChannel, isAutomated = fa const endTime = new Date(Date.now() + durationHours * 60 * 60 * 1000); const generalConfig = getConfig(client, 'configuration') || {}; const initiator = isAutomated - ? localize('staff-management-system', 'label-system') - : interactionOrChannel.user.toString(); + ? localize('staff-management-system', 'label-system') + : interactionOrChannel.user.toString(); const responseButtonRow = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId('staff-mgmt_ac-respond') - .setLabel(localize('staff-management-system', 'ac-confirm-btn')) - .setStyle(ButtonStyle.Success) - .setEmoji('✅') - ) - .toJSON(); - - let msgOpts = await embedTypeV2(config.checkMessage, { - '%end-time%': dateToDiscordTimestamp(endTime, 'F'), - '%duration%': durationHours.toString(), - '%staff-mention%': formatRoleMentions(generalConfig.staffRoles), - '%supervisor-mention%': formatRoleMentions(generalConfig.supervisorRoles), - '%management-mention%': formatRoleMentions(generalConfig.managementRoles), - '%initiator%': initiator - }, - { - components: [responseButtonRow] - } - ); + .addComponents( + new ButtonBuilder() + .setCustomId('staff-mgmt_ac-respond') + .setLabel(localize('staff-management-system', 'ac-confirm-btn')) + .setStyle(ButtonStyle.Success) + .setEmoji('✅') + ) + .toJSON(); + + let msgOpts = await embedTypeV2(config.checkMessage, { + '%end-time%': dateToDiscordTimestamp(endTime, 'F'), + '%duration%': durationHours.toString(), + '%staff-mention%': formatRoleMentions(generalConfig.staffRoles), + '%supervisor-mention%': formatRoleMentions(generalConfig.supervisorRoles), + '%management-mention%': formatRoleMentions(generalConfig.managementRoles), + '%initiator%': initiator + }, { + components: [responseButtonRow] + }); if (msgOpts?.content?.trim() === '') delete msgOpts.content; @@ -1495,8 +1461,8 @@ async function endActivityCheckProcess(client, activeCheck) { } }); const initiator = (activeCheck.isAutomated || !activeCheck.initiatorId) - ? localize('staff-management-system', 'label-system') - : `<@${activeCheck.initiatorId}>`; + ? localize('staff-management-system', 'label-system') + : `<@${activeCheck.initiatorId}>`; expectedMembers.forEach(member => { if (respondedUserIds.has(member.id)) return responded.push(member); @@ -1542,7 +1508,8 @@ async function endActivityCheckProcess(client, activeCheck) { await msg.edit(endedMessage); } - } catch (e) {} + } catch (e) { + } const embed = applyFooter(client, new EmbedBuilder() .setTitle(localize('staff-management-system', 'ac-res-title')) @@ -1743,8 +1710,8 @@ async function generateReviewHistoryResponse(client, targetUser, page = 1) { embed.addFields({ name: localize('staff-management-system', 'label-hist'), value: rows.length > 0 - ? rows.map(r => `**${"⭐".repeat(r.stars)}** ${localize('staff-management-system', 'label-by')} <@${r.authorId}>${r.messageUrl - ? ` • [Jump](${r.messageUrl})` + ? rows.map(r => `**${"⭐".repeat(r.stars)}** ${localize('staff-management-system', 'label-by')} <@${r.authorId}>${r.messageUrl + ? ` • [Jump](${r.messageUrl})` : ''}\n"${r.comment}"`).join('\n\n') : localize('staff-management-system', 'p-no-hist') }); @@ -1802,5 +1769,6 @@ module.exports = { endActivityCheckProcess, submitReview, getReviewHistory, - generateReviewHistoryResponse -}; \ No newline at end of file + generateReviewHistoryResponse, + getIsoWeekNumber +}; diff --git a/modules/starboard/events/messageReactionAdd.js b/modules/starboard/events/messageReactionAdd.js index b7c80509..28a6a027 100644 --- a/modules/starboard/events/messageReactionAdd.js +++ b/modules/starboard/events/messageReactionAdd.js @@ -1,6 +1,6 @@ const handleStarboard = require('../handleStarboard.js'); module.exports.run = async (client, msgReaction, user) => { - handleStarboard(client, msgReaction, user, false); + await handleStarboard(client, msgReaction, user, false); }; module.exports.allowPartial = true; \ No newline at end of file diff --git a/modules/starboard/events/messageReactionRemove.js b/modules/starboard/events/messageReactionRemove.js index 5165eda4..e0994e1b 100644 --- a/modules/starboard/events/messageReactionRemove.js +++ b/modules/starboard/events/messageReactionRemove.js @@ -1,6 +1,6 @@ const handleStarboard = require('../handleStarboard.js'); module.exports.run = async (client, msgReaction, user) => { - handleStarboard(client, msgReaction, user, true); + await handleStarboard(client, msgReaction, user, true); }; module.exports.allowPartial = true; diff --git a/modules/starboard/handleStarboard.js b/modules/starboard/handleStarboard.js index ded74bf6..40cd0427 100644 --- a/modules/starboard/handleStarboard.js +++ b/modules/starboard/handleStarboard.js @@ -13,6 +13,7 @@ module.exports = async (client, msgReaction, user, isReactionRemove = false) => if (!msg.guild) return; if (msg.guild.id !== client.guildID) return; if (msgReaction.partial) msgReaction = await msgReaction.fetch(); + if (msg.partial) await msg.fetch(); const starConfig = client.configurations['starboard']['config']; if (!starConfig || starConfig.emoji !== msgReaction.emoji.toString()) return; @@ -20,7 +21,7 @@ module.exports = async (client, msgReaction, user, isReactionRemove = false) => const channel = client.channels.cache.get(starConfig.channelId); if (!channel) return disableModule('starboard', localize('partner-list', 'channel-not-found', {c: starConfig.channelId})); - if ((msg.channel.nsfw && !channel.nsfw) || starConfig.excludedChannels.includes(msg.channel.id) || starConfig.excludedRoles.some(r => msg.member.roles.cache.has(r))) return; + if ((msg.channel.nsfw && !channel.nsfw) || starConfig.excludedChannels.includes(msg.channel.id) || starConfig.excludedRoles.some(r => msg.member?.roles.cache.has(r))) return; if (!starConfig.selfStar && user.id === msg.author.id) return msgReaction.users.remove(user.id).catch(() => { }); diff --git a/modules/suggestions/module.json b/modules/suggestions/module.json index 202e130d..7b0961f9 100644 --- a/modules/suggestions/module.json +++ b/modules/suggestions/module.json @@ -2,8 +2,8 @@ "name": "suggestions", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/suggestions", "commands-dir": "/commands", diff --git a/modules/team-list/config.json b/modules/team-list/config.json index 4c1e39c7..5a3921c6 100644 --- a/modules/team-list/config.json +++ b/modules/team-list/config.json @@ -75,4 +75,4 @@ "default": false } ] -} +} \ No newline at end of file diff --git a/modules/team-list/events/botReady.js b/modules/team-list/events/botReady.js index 433a7edf..8d12530c 100644 --- a/modules/team-list/events/botReady.js +++ b/modules/team-list/events/botReady.js @@ -15,6 +15,39 @@ const statusIcons = { 'offline': '⚫' }; +/** + * Builds the user-list string shown for a single role field. + * Extracted (behavior-preserving) from updateEmbedsIfNeeded so the + * status-line vs comma-list formatting, the highest-role dedup, and the + * empty-role fallback can be unit-tested. Mutates `listedUserIDs` to track + * which users have already been printed (used by onlineShowHighestRole). + * + * @param {Iterable} membersWithRole members holding this role (Map values / array) + * @param {Object} role the role being rendered (needs toString()) + * @param {Object} channelConfig the per-channel team-list config element + * @param {string[]} listedUserIDs accumulator of already-listed user ids (mutated) + * @returns {string} + */ +function buildUserString(membersWithRole, role, channelConfig, listedUserIDs) { + let userString = ''; + for (const member of membersWithRole) { + if (listedUserIDs.includes(member.user.id) && channelConfig.onlineShowHighestRole) continue; + listedUserIDs.push(member.user.id); + const status = (member.presence || {status: 'offline'}).status; + userString = userString + (channelConfig.includeStatus + ? `* ${member.user.toString()}: ${statusIcons[status]} ${localize('team-list', status)}\n` + : `${member.user.toString()}, `); + } + if (userString === '') userString = localize('team-list', 'no-users-with-role', {r: role.toString()}); + else if (!channelConfig.includeStatus) userString = userString.substring(0, userString.length - 2); + return userString; +} + +module.exports.__test = { + buildUserString, + statusIcons +}; + module.exports.run = async function (client) { await updateEmbedsIfNeeded(client); const job = schedule.scheduleJob('1,16,31,46 * * * *', async () => { @@ -58,14 +91,8 @@ async function updateEmbedsIfNeeded(client) { const listedUserIDs = []; let fieldCount = 0; for (const role of roles.values()) { - let userString = ''; - for (const member of guildMembers.filter(m => m.roles.cache.has(role.id)).values()) { - if (listedUserIDs.includes(member.user.id) && channelConfig.onlineShowHighestRole) continue; - listedUserIDs.push(member.user.id); - userString = userString + (channelConfig.includeStatus ? `* ${member.user.toString()}: ${statusIcons[(member.presence || {status: 'offline'}).status]} ${localize('team-list', (member.presence || {status: 'offline'}).status)}\n` : `${member.user.toString()}, `); - } - if (userString === '') userString = localize('team-list', 'no-users-with-role', {r: role.toString()}); - else if (!channelConfig.includeStatus) userString = userString.substring(0, userString.length - 2); + const membersWithRole = guildMembers.filter(m => m.roles.cache.has(role.id)).values(); + const userString = buildUserString(membersWithRole, role, channelConfig, listedUserIDs); fieldCount++; embed.addField(channelConfig['nameOverwrites'][role.id] || role.name, truncate((channelConfig['descriptions'][role.id] ? `${channelConfig['descriptions'][role.id]}\n` : '') + userString, 1024)); } diff --git a/modules/team-list/module.json b/modules/team-list/module.json index 72aa9446..804320d1 100644 --- a/modules/team-list/module.json +++ b/modules/team-list/module.json @@ -3,8 +3,8 @@ "fa-icon": "fa-user-tie", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "events-dir": "/events", "models-dir": "/models", diff --git a/modules/temp-channels/channel-settings.js b/modules/temp-channels/channel-settings.js index 63981d8a..4baee3f2 100644 --- a/modules/temp-channels/channel-settings.js +++ b/modules/temp-channels/channel-settings.js @@ -1,6 +1,7 @@ const {client} = require('../../main'); const {Op} = require('sequelize'); const {embedType, formatDiscordUserName} = require('../../src/functions/helpers'); +const {TextDisplayBuilder} = require('discord.js'); const {localize} = require('../../src/functions/localize'); /** @@ -224,7 +225,11 @@ module.exports.usersList = async function (interaction) { interaction.editReply(embedType(listMsg, {'%users%': allowedUsers}, {ephemeral: true})); } else { const result = embedType(listMsg, {}, {ephemeral: true}); - if (result.content) result.content += ' ' + allowedUsers; + const schema = listMsg && typeof listMsg === 'object' ? (listMsg._schema || 'v2') : 'v2'; + if (schema === 'v4') { + if (!result.components) result.components = []; + result.components.push(new TextDisplayBuilder().setContent(allowedUsers.trim())); + } else if (result.content) result.content += ' ' + allowedUsers; else if (result.embeds && result.embeds[0]) result.embeds[0].description = (result.embeds[0].description || '') + '\n' + allowedUsers; interaction.editReply(result); } diff --git a/modules/temp-channels/events/botReady.js b/modules/temp-channels/events/botReady.js index 0bb8e50f..c0966297 100644 --- a/modules/temp-channels/events/botReady.js +++ b/modules/temp-channels/events/botReady.js @@ -1,9 +1,4 @@ -const {migrate} = require('../../../src/functions/helpers'); const {client} = require('../../../main'); -const { - migrationStart, - migrationEnd -} = require('../../../main'); const {sendMessage} = require('../channel-settings'); const {localize} = require('../../../src/functions/localize'); const {scheduleJob} = require('node-schedule'); @@ -12,42 +7,6 @@ const {Op} = require('sequelize'); module.exports.run = async function () { const moduleConfig = client.configurations['temp-channels']['config']; const settingsChannel = client.channels.cache.get(moduleConfig['settingsChannel']); - await migrate('temp-channels', 'TempChannelV1', 'TempChannel'); - - // Migration V2: add archivedAt column - const dbVersionV2 = await client.models['DatabaseSchemeVersion'].findOne({ - where: { - model: 'temp-channels_TempChannel', - version: 'V2' - } - }); - if (!dbVersionV2) { - migrationStart(); - try { - client.logger.info('[temp-channels] Running V2 migration (adding archivedAt field)...'); - const data = await client.models['temp-channels']['TempChannel'].findAll({ - attributes: ['id', 'creatorID', 'noMicChannel', 'allowedUsers', 'isPublic'] - }).catch(() => []); - await client.models['temp-channels']['TempChannel'].sync({force: true}); - for (const tc of data) { - await client.models['temp-channels']['TempChannel'].create({ - id: tc.id, - creatorID: tc.creatorID, - noMicChannel: tc.noMicChannel, - allowedUsers: tc.allowedUsers, - isPublic: tc.isPublic, - archivedAt: null - }); - } - client.logger.info('[temp-channels] V2 migration complete.'); - await client.models['DatabaseSchemeVersion'].upsert({ - model: 'temp-channels_TempChannel', - version: 'V2' - }); - } finally { - migrationEnd(); - } - } // Cleanup orphaned temp channels on startup const tempChannels = await client.models['temp-channels']['TempChannel'].findAll(); diff --git a/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js b/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js new file mode 100644 index 00000000..8444920b --- /dev/null +++ b/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js @@ -0,0 +1,46 @@ +const OLD_TABLE = 'temp-channel_TempChannels'; +const NEW_TABLE = 'temp-channel_TempChannelsv2'; + +/* + * Replaces the old `migrate('temp-channels', 'TempChannelV1', 'TempChannel')` call + * (which used the now-deprecated row-by-row JavaScript helper in src/functions/helpers.js) + * with a SQL-level INSERT INTO ... SELECT inside a transaction. + * + * The legacy helper was not transactional and ran one create+destroy per row, so a + * crash mid-loop could leave the source table partially drained while the destination + * already had the copied rows. This version is atomic. + * + * Idempotent: if the old V1 table no longer exists (already migrated under the legacy + * helper, or fresh install where the V1 schema was never present), the body is a no-op. + */ +module.exports = { + tables: [OLD_TABLE, NEW_TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + const allTables = await queryInterface.showAllTables(); + const tableSet = new Set(allTables.map(t => (typeof t === 'object' ? t.tableName : t))); + const oldExists = tableSet.has(OLD_TABLE); + const newExists = tableSet.has(NEW_TABLE); + if (!oldExists || !newExists) return; + + await sequelize.transaction(async (transaction) => { + await sequelize.query( + `INSERT OR IGNORE INTO "${NEW_TABLE}" (id, "creatorID", "noMicChannel", "createdAt", "updatedAt") + SELECT id, "creatorID", "noMicChannel", "createdAt", "updatedAt" FROM "${OLD_TABLE}"`, + {transaction} + ); + await sequelize.query(`DELETE FROM "${OLD_TABLE}"`, {transaction}); + }); + }, + down: async () => { + + /* + * No-op: copying rows back to a now-empty V1 schema is not a meaningful + * rollback, and the old helper had no down path either. + */ + } +}; \ No newline at end of file diff --git a/modules/temp-channels/migrations/temp-channels_TempChannel__V2.js b/modules/temp-channels/migrations/temp-channels_TempChannel__V2.js new file mode 100644 index 00000000..a982f105 --- /dev/null +++ b/modules/temp-channels/migrations/temp-channels_TempChannel__V2.js @@ -0,0 +1,39 @@ +const {DataTypes} = require('sequelize'); + +/* + * Model's configured tableName is `temp-channel_TempChannelsv2` (singular `channel`, + * trailing `v2`). Legacy markers use the singular form too. + */ +const TABLE = 'temp-channel_TempChannelsv2'; + +module.exports = { + tables: [TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (!description.archivedAt) { + await queryInterface.addColumn(TABLE, 'archivedAt', { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null + }, {transaction}); + } + }); + }, + down: async ({ + context: { + queryInterface, + sequelize + } + }) => { + await sequelize.transaction(async (transaction) => { + const description = await queryInterface.describeTable(TABLE).catch(() => ({})); + if (description.archivedAt) await queryInterface.removeColumn(TABLE, 'archivedAt', {transaction}); + }); + } +}; \ No newline at end of file diff --git a/modules/tic-tak-toe/commands/tic-tac-toe.js b/modules/tic-tak-toe/commands/tic-tac-toe.js index 234ad037..f60dff86 100644 --- a/modules/tic-tak-toe/commands/tic-tac-toe.js +++ b/modules/tic-tak-toe/commands/tic-tac-toe.js @@ -2,6 +2,79 @@ const {localize} = require('../../../src/functions/localize'); const {ComponentType} = require('discord.js'); const {randomElementFromArray} = require('../../../src/functions/helpers'); +/** + * Returns true if every cell of the 3x3 grid is filled (non-null). + * @param {Object} grid grid[row][col] -> owner id or null + * @returns {boolean} + */ +function isBoardFull(grid) { + for (const rID in grid) { + for (const id in grid[rID]) { + if (grid[rID][id] === null) return false; + } + } + return true; +} + +/** + * Detects whether `playerId` has a winning line on the 3x3 grid. + * Mirrors the original in-game neighbour/diagonal scan: a win is two same-owner + * neighbours in a horizontal or vertical direction from one of the player's + * cells, or a full diagonal through the centre. + * @param {Object} grid grid[row][col] -> owner id or null (rows/cols are "1".."3") + * @param {string} playerId owner id to test for a win + * @returns {boolean} + */ +function detectWin(grid, playerId) { + /** + * @param {string|number} rID + * @param {string|number} id + * @returns {{below: (boolean|null), left: (boolean|null), above: (boolean|null), right: (boolean|null)}|void} + */ + function checkBlock(rID, id) { + rID = parseInt(rID); + id = parseInt(id); + const value = grid[rID][id]; + if (value !== playerId) return; + let above, below; + if (!grid[rID - 1]) above = null; + else above = grid[rID - 1][id] === value; + if (!grid[rID + 1]) below = null; + else below = grid[rID + 1][id] === value; + const left = typeof grid[rID][id - 1] === 'undefined' ? null : (grid[rID][id - 1] === value); + const right = typeof grid[rID][id + 1] === 'undefined' ? null : (grid[rID][id + 1] === value); + return { + above, + below, + left, + right + }; + } + + for (const rID in grid) { + for (const id in grid[rID]) { + const cB = checkBlock(rID, id); + if (!cB) continue; + let x = 0; + let y = 0; + if (cB.above) y++; + if (cB.below) y++; + if (cB.left) x++; + if (cB.right) x++; + let diagPass = false; + if (parseInt(rID) === 2 && parseInt(id) === 2) { + if (grid[1][1] === playerId && grid[3][3] === playerId) diagPass = true; + if (grid[1][3] === playerId && grid[3][1] === playerId) diagPass = true; + } + if (x === 2 || y === 2 || diagPass) return true; + } + } + return false; +} + +module.exports.detectWin = detectWin; +module.exports.isBoardFull = isBoardFull; + module.exports.run = async function (interaction) { const member = interaction.options.getMember('user', true); if (member.user.id === interaction.user.id) return interaction.reply({ @@ -71,57 +144,16 @@ module.exports.run = async function (interaction) { */ function checkGameEnded() { if (ended) return true; - let allPassed = true; const lastUser = currentUser.user.id === interaction.user.id ? member : interaction.member; - /** - * Returns values from blocks above, below, left and right if the block is user owned - * @param rID ID of the row - * @param id ID of column - * @private - * @returns {{below: boolean, left: boolean, above: boolean, right: boolean}|void} - */ - function checkBlock(rID, id) { - rID = parseInt(rID); - id = parseInt(id); - const value = grid[rID][id]; - if (value !== lastUser.user.id) return; - let above, below; - if (!grid[rID - 1]) above = null; - else above = grid[rID - 1][id] === value; - if (!grid[rID + 1]) below = null; - else below = grid[rID + 1][id] === value; - const left = typeof grid[rID][id - 1] === 'undefined' ? null : (grid[rID][id - 1] === value); - const right = typeof grid[rID][id + 1] === 'undefined' ? null : (grid[rID][id + 1] === value); - return {above, below, left, right}; - } - - for (const rID in grid) { - for (const id in grid[rID]) { - if (grid[rID][id] === null) allPassed = false; - const cB = checkBlock(rID, id); - if (!cB) continue; - let x = 0; - let y = 0; - if (cB.above) y++; - if (cB.below) y++; - if (cB.left) x++; - if (cB.right) x++; - let diagPass = false; - if (parseInt(rID) === 2 && parseInt(id) === 2) { - if (grid[1][1] === lastUser.user.id && grid[3][3] === lastUser.user.id) diagPass = true; - if (grid[1][3] === lastUser.user.id && grid[3][1] === lastUser.user.id) diagPass = true; - } - if (x === 2 || y === 2 || diagPass) { - ended = true; - gameEndReasonType = 'win'; - currentUser = lastUser; - return true; - } - } + if (detectWin(grid, lastUser.user.id)) { + ended = true; + gameEndReasonType = 'win'; + currentUser = lastUser; + return true; } - if (allPassed) { + if (isBoardFull(grid)) { ended = true; gameEndReasonType = 'draw'; return true; @@ -246,4 +278,4 @@ module.exports.config = { description: localize('tic-tac-toe', 'user-description') } ] -}; +}; \ No newline at end of file diff --git a/modules/tic-tak-toe/module.json b/modules/tic-tak-toe/module.json index e5f682ed..98d95714 100644 --- a/modules/tic-tak-toe/module.json +++ b/modules/tic-tak-toe/module.json @@ -3,8 +3,8 @@ "humanReadableName": "Tic Tac Toe", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "fa-icon": "fa-solid fa-border-all", "description": "Let your users play Tick-Tac-Toe against each other!", diff --git a/modules/tickets/config.json b/modules/tickets/config.json index d10e46a1..3c995c4f 100644 --- a/modules/tickets/config.json +++ b/modules/tickets/config.json @@ -87,6 +87,7 @@ { "name": "creation-message", "humanName": "Ticket-Created Message", + "pro": true, "type": "string", "allowEmbed": true, "description": "This message will get sent in new tickets. The close buttons will be added.", @@ -139,14 +140,16 @@ "humanName": "Ticket create button", "default": "Create ticket 🎫", "description": "Button for creating a ticket", - "type": "string" + "type": "string", + "pro": true }, { "name": "ticket-close-button", "humanName": "Ticket close button", "default": "❎ Close ticket", "description": "Button for closing a ticket", - "type": "string" + "type": "string", + "pro": true } ] -} +} \ No newline at end of file diff --git a/modules/tickets/events/botReady.js b/modules/tickets/events/botReady.js index a94c4603..f66c5aa0 100644 --- a/modules/tickets/events/botReady.js +++ b/modules/tickets/events/botReady.js @@ -1,11 +1,13 @@ const {ChannelType} = require('discord.js'); -const {embedType, disableModule, migrate} = require('../../../src/functions/helpers'); +const { + embedType, + disableModule +} = require('../../../src/functions/helpers'); const {localize} = require('../../../src/functions/localize'); module.exports.run = async function (client) { const moduleConfig = client.configurations['tickets']['config']; const messageModel = client.models['tickets']['TicketMessage']; - await migrate('tickets', 'TicketV1', 'Ticket'); for (const element of moduleConfig) { for (const element2 of moduleConfig) { if (moduleConfig.indexOf(element) === moduleConfig.indexOf(element2) && moduleConfig.indexOf(element) !== moduleConfig.indexOf(element2)) return disableModule('tickets', localize('tickets', 'button-not-uniqe')); diff --git a/modules/tickets/events/interactionCreate.js b/modules/tickets/events/interactionCreate.js index 2d2ee1eb..cd4681ef 100644 --- a/modules/tickets/events/interactionCreate.js +++ b/modules/tickets/events/interactionCreate.js @@ -24,14 +24,20 @@ module.exports.run = async function (client, interaction) { } }); if (!ticket) return; + + /* + * Acknowledge immediately: locking the channel and sending messages can take + * longer than Discord's 3s interaction window, which would otherwise expire the + * token and produce an "Unknown interaction" error when we reply below. + */ + await interaction.deferReply({ephemeral: true}); await interaction.channel.send({ content: localize('tickets', 'closing-ticket', {u: interaction.user.toString()}), allowedMentions: {parse: []} }); await lockChannel(interaction.channel, [], localize('tickets', 'ticket-closed-audit-log', {u: formatDiscordUserName(interaction.user)})); - interaction.reply({ - ephemeral: true, + await interaction.editReply({ content: localize('tickets', 'ticket-closed-successfully') }); ticket.open = false; @@ -75,6 +81,13 @@ module.exports.run = async function (client, interaction) { }, 20000); } if (interaction.customId.startsWith('create-ticket-') && parseFloat(interaction.customId.replaceAll('create-ticket-', '')) === moduleConfig.indexOf(element)) { + + /* + * Acknowledge immediately: creating the channel, sending the creation message and + * pinning it routinely take longer than Discord's 3s interaction window. Replying + * only after that work expired the token and surfaced as "Unknown interaction". + */ + await interaction.deferReply({ephemeral: true}); const existingTicket = await client.models['tickets']['Ticket'].findOne({ where: { userID: interaction.user.id, @@ -85,8 +98,7 @@ module.exports.run = async function (client, interaction) { if (existingTicket) { const ticketChannel = await interaction.guild.channels.fetch(existingTicket.channelID).catch(() => { }); - if (ticketChannel) return interaction.reply({ - ephemeral: true, + if (ticketChannel) return await interaction.editReply({ content: localize('tickets', 'existing-ticket', {c: `<#${existingTicket.channelID}>`}) }); existingTicket.open = false; @@ -142,8 +154,7 @@ module.exports.run = async function (client, interaction) { }] }])); await msg.pin(); - interaction.reply({ - ephemeral: true, + await interaction.editReply({ content: '✅ ' + localize('tickets', 'ticket-created', {c: channel.toString()}) }); } diff --git a/modules/tickets/migrations/tickets_Ticket__V1.js b/modules/tickets/migrations/tickets_Ticket__V1.js new file mode 100644 index 00000000..927c2fc4 --- /dev/null +++ b/modules/tickets/migrations/tickets_Ticket__V1.js @@ -0,0 +1,44 @@ +const OLD_TABLE = 'ticket_Ticketv1'; +const NEW_TABLE = 'ticket_Ticketv2'; + +/* + * Replaces `migrate('tickets', 'TicketV1', 'Ticket')` (the legacy row-by-row helper + * in src/functions/helpers.js) with a SQL-level INSERT INTO ... SELECT inside a + * transaction. The new Ticket schema adds a `type` column; existing V1 rows have no + * value for it, so it defaults to NULL. + * + * Idempotent: if either table is missing (already migrated under the legacy helper, + * or a fresh install where the V1 schema was never present), the body is a no-op. + */ +module.exports = { + tables: [OLD_TABLE, NEW_TABLE], + up: async ({ + context: { + queryInterface, + sequelize + } + }) => { + const allTables = await queryInterface.showAllTables(); + const tableSet = new Set(allTables.map(t => (typeof t === 'object' ? t.tableName : t))); + if (!tableSet.has(OLD_TABLE) || !tableSet.has(NEW_TABLE)) return; + + await sequelize.transaction(async (transaction) => { + await sequelize.query( + `INSERT + OR IGNORE INTO "${NEW_TABLE}" (id, open, "userID", "channelID", "msgLogURL", "msgCount", "addedUsers", "createdAt", "updatedAt") + SELECT id, open, "userID", "channelID", "msgLogURL", "msgCount", "addedUsers", "createdAt", "updatedAt" + FROM "${OLD_TABLE}"`, + {transaction} + ); + await sequelize.query(`DELETE + FROM "${OLD_TABLE}"`, {transaction}); + }); + }, + down: async () => { + + /* + * No-op: copying rows back to a now-empty V1 schema is not a meaningful + * rollback, and the old helper had no down path either. + */ + } +}; \ No newline at end of file diff --git a/modules/tickets/module.json b/modules/tickets/module.json index 300a6de5..0f185484 100644 --- a/modules/tickets/module.json +++ b/modules/tickets/module.json @@ -2,8 +2,8 @@ "name": "tickets", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "fa-icon": "fas fa-ticket-simple", "events-dir": "/events", diff --git a/modules/twitch-notifications/configs/streamers.json b/modules/twitch-notifications/configs/streamers.json index 83cadb8e..55e2df60 100644 --- a/modules/twitch-notifications/configs/streamers.json +++ b/modules/twitch-notifications/configs/streamers.json @@ -74,4 +74,4 @@ "dependsOn": "liveRole" } ] -} +} \ No newline at end of file diff --git a/modules/twitch-notifications/events/botReady.js b/modules/twitch-notifications/events/botReady.js index 633019e1..9545d19b 100644 --- a/modules/twitch-notifications/events/botReady.js +++ b/modules/twitch-notifications/events/botReady.js @@ -4,9 +4,30 @@ const {embedType} = require('../../../src/functions/helpers'); const {ApiClient} = require('@twurple/api'); -const {ClientCredentialsAuthProvider} = require('@twurple/auth'); +const {AppTokenAuthProvider} = require('@twurple/auth'); const {localize} = require('../../../src/functions/localize'); +const INTERVAL_SECONDS = 180; + +/** + * Classifies a streamer poll result into the action the poller should take. + * Extracted (behavior-preserving) from the `start` branch ladder so the + * decision logic can be unit-tested without the Twitch API / Discord client. + * + * @param {('userNotFound'|null|Object)} stream sentinel string, null (offline) or a HelixStream-like object with `startDate` + * @param {?{startedAt: string}} streamer persisted streamer row (null if unknown) + * @returns {'userNotFound'|'newLive'|'reLive'|'offline'|'noChange'} + */ +function classifyStreamUpdate(stream, streamer) { + if (stream === 'userNotFound') return 'userNotFound'; + if (stream !== null && !streamer) return 'newLive'; + if (stream !== null && stream.startDate.toString() !== streamer.startedAt) return 'reLive'; + if (stream === null) return 'offline'; + return 'noChange'; +} + +module.exports.__test = {classifyStreamUpdate}; + /** * General program * @param {Client} client Discord js Client @@ -84,21 +105,22 @@ function twitchNotifications(client, apiClient) { } }); const stream = await isStreamLive(value.streamer); - if (stream === 'userNotFound') { + const action = classifyStreamUpdate(stream, streamer); + if (action === 'userNotFound') { return client.logger.error(`[twitch-notifications] ` + localize('twitch-notifications', 'user-not-on-twitch', {u: value})); - } else if (stream !== null && !streamer) { + } else if (action === 'newLive') { client.models['twitch-notifications']['streamer'].create({ name: value.streamer.toLowerCase(), startedAt: stream.startDate.toString() }); sendMsg(stream.userDisplayName, stream.gameName, stream.thumbnailUrl, streamers[index]['liveMessageChannel'], stream.title, index); addLiveRole(streamers[index]['id'], streamers[index]['role'], streamers[index]['liveRole']); - } else if (stream !== null && stream.startDate.toString() !== streamer.startedAt) { + } else if (action === 'reLive') { streamer.startedAt = stream.startDate.toString(); streamer.save(); sendMsg(stream.userDisplayName, stream.gameName, stream.thumbnailUrl, streamers[index]['liveMessageChannel'], stream.title, index); addLiveRole(streamers[index]['id'], streamers[index]['role'], streamers[index]['liveRole']); - } else if (stream === null) { + } else if (action === 'offline') { if (!streamers[index]['liveRole']) return; if (!streamers[index]['id'] || streamers[index]['id'] === '' || !streamers[index]['role'] || streamers[index]['role'] === '') return; const member = client.guild.members.cache.get(streamers[index]['id']); @@ -115,20 +137,18 @@ function twitchNotifications(client, apiClient) { module.exports.run = async (client) => { const config = client.configurations['twitch-notifications']['config']; - - if (!config['twitchClientID'] || !config['clientSecret']) { + if (!config || !config['twitchClientID'] || !config['clientSecret']) { client.logger.error('[twitch-notifications] Missing twitchClientID or clientSecret in configs/config.json — module disabled. Create a Twitch app at https://dev.twitch.tv/console/apps to obtain credentials.'); return; } - const authProvider = new ClientCredentialsAuthProvider(config['twitchClientID'], config['clientSecret']); + const authProvider = new AppTokenAuthProvider(config['twitchClientID'], config['clientSecret']); const apiClient = new ApiClient({authProvider}); await twitchNotifications(client, apiClient); - const interval = (config['interval'] || 180) * 1000; + const intervalSeconds = config['interval'] || INTERVAL_SECONDS; const twitchCheckInterval = setInterval(() => { twitchNotifications(client, apiClient); - }, interval); - + }, intervalSeconds * 1000); client.intervals.push(twitchCheckInterval); }; \ No newline at end of file diff --git a/modules/uno/commands/uno.js b/modules/uno/commands/uno.js index c9974d5c..2f700030 100644 --- a/modules/uno/commands/uno.js +++ b/modules/uno/commands/uno.js @@ -481,4 +481,16 @@ module.exports.config = { name: 'uno', description: localize('uno', 'command-description'), defaultPermission: true +}; + +// Exposed for unit testing of the pure game rules. +module.exports.__test = { + canUseCard, + nextPlayer, + gameMsg, + buildDeck, + perPlayerHandler, + cards, + colors, + colorEmojis }; \ No newline at end of file diff --git a/modules/welcomer/baseRoles.js b/modules/welcomer/baseRoles.js new file mode 100644 index 00000000..8e28ad1c --- /dev/null +++ b/modules/welcomer/baseRoles.js @@ -0,0 +1,368 @@ +/* + * `localize` is required lazily inside functions that need it, so this module + * stays unit-testable without triggering main.js via locales/localize.js. + */ + +const recentReadds = new Set(); +const watchdogTimers = new Map(); +const pendingDebounces = new Map(); + +/** + * @private + * @param {Object} client + * @returns {boolean} + */ +function moderationEnabled(client) { + return !!(client.modules && client.modules.moderation && client.modules.moderation.enabled); +} + +/** + * @private + * @param {Object} client + * @param {String} key Top-level key under client.configurations.moderation + * @returns {Object|null} + */ +function moderationConfig(client, key) { + if (!moderationEnabled(client)) return null; + return (client.configurations && client.configurations.moderation && client.configurations.moderation[key]) || null; +} + +/** + * Returns true when the member must NOT receive welcome roles from the base-role flow. + * @param {Object} member discord.js GuildMember (or test stub) + * @param {Object} client discord.js Client (or test stub) + * @returns {Promise} + */ +async function isInHoldingState(member, client) { + if (member.user && member.user.bot) return true; + + const welcomerConfig = client.configurations.welcomer.config; + if (member.pending && !welcomerConfig['assign-roles-immediately']) return true; + + if (moderationEnabled(client)) { + const modConfig = moderationConfig(client, 'config'); + const quarantineRoleID = modConfig && modConfig['quarantine-role-id']; + if (quarantineRoleID && member.roles.cache.has(quarantineRoleID)) return true; + + const QuarantineState = client.models && client.models.moderation && client.models.moderation.QuarantineState; + if (QuarantineState) { + const row = await QuarantineState.findByPk(member.id).catch(() => null); + if (row) return true; + } + + const joinGate = moderationConfig(client, 'joinGate'); + if (joinGate && joinGate.enabled && joinGate.action === 'give-role' && joinGate.roleID && member.roles.cache.has(joinGate.roleID)) return true; + + const antiJoinRaid = moderationConfig(client, 'antiJoinRaid'); + if (antiJoinRaid && antiJoinRaid.enabled && antiJoinRaid.action === 'give-role' && antiJoinRaid.roleID && member.roles.cache.has(antiJoinRaid.roleID)) return true; + } + + return false; +} + +/** + * Decides what (if anything) should happen for a single member under the base-role policy. + * @param {Object} member + * @param {Object} client + * @returns {Promise<{skip: boolean, missingRoleIDs: string[]}>} + */ +async function evaluateMember(member, client) { + if (await isInHoldingState(member, client)) return {skip: true, missingRoleIDs: []}; + const roleIDs = client.configurations.welcomer.config['give-roles-on-join'] || []; + const missingRoleIDs = roleIDs.filter(id => !member.roles.cache.has(id)); + return {skip: false, missingRoleIDs}; +} + +/** + * Iterates the cached members and grants missing join roles to anyone not in a holding state. + * Called from the daily schedule and the 60s post-botReady initial sweep. + * @param {Object} client + * @returns {Promise<{scanned:number, granted:number, skipped:number, failed:number}|undefined>} + */ +async function runSync(client) { + const welcomerConfig = client.configurations.welcomer.config; + if (!welcomerConfig['treat-welcome-roles-as-base-roles']) return; + const roleIDs = welcomerConfig['give-roles-on-join'] || []; + if (roleIDs.length === 0) return; + + const members = client.guild ? client.guild.members.cache : null; + if (!members) return; + + const {localize} = require('../../src/functions/localize'); + const counts = {scanned: 0, granted: 0, skipped: 0, failed: 0}; + client.logger.info(localize('welcomer', 'base-role-sync-start', {c: members.size})); + + for (const member of members.values()) { + counts.scanned++; + let evaluation; + try { + evaluation = await evaluateMember(member, client); + } catch (e) { + counts.failed++; + client.logger.warn(`[welcomer/base-role-sync] evaluateMember failed for ${member.id}: ${e && e.message ? e.message : String(e)}`); + continue; + } + if (evaluation.skip || evaluation.missingRoleIDs.length === 0) { + counts.skipped++; + continue; + } + try { + await member.roles.add(evaluation.missingRoleIDs, localize('welcomer', 'base-role-audit-reason')); + counts.granted++; + } catch (e) { + counts.failed++; + const sentryId = client.captureException ? client.captureException(e, { + module: 'welcomer', + phase: 'base-role-sync', + userID: member.id, + roleIDs: evaluation.missingRoleIDs + }) : null; + client.logger.error(localize('welcomer', 'assign-role-failed', { + u: member.id, + r: evaluation.missingRoleIDs.join(', '), + e: (e && e.message) ? e.message : String(e) + }) + (sentryId ? ` [Sentry: ${sentryId}]` : '')); + } + } + + client.logger.info(localize('welcomer', 'base-role-sync-done', { + s: counts.scanned, + g: counts.granted, + k: counts.skipped, + f: counts.failed + })); + return counts; +} + +const {AuditLogEvent} = require('discord-api-types/v10'); + +const DEBOUNCE_MS = 1500; +const LOOP_GUARD_MS = 5000; +const WATCHDOG_MS = 5000; +const AUDIT_LOG_LOOKBACK_MS = 10_000; + +/** + * Fetches recent MemberRoleUpdate audit entries that removed at least one of the given role IDs + * from this member within the lookback window. Returns most-recent-first. + * @private + * @param {Object} guild + * @param {string} memberID + * @param {string[]} roleIDs + * @returns {Promise} + */ +async function fetchRecentJoinRoleRemovals(guild, memberID, roleIDs) { + const audit = await guild.fetchAuditLogs({type: AuditLogEvent.MemberRoleUpdate, limit: 5}).catch(() => null); + if (!audit) return []; + const cutoff = Date.now() - AUDIT_LOG_LOOKBACK_MS; + const matches = []; + for (const entry of audit.entries.values()) { + if (!entry.target || entry.target.id !== memberID) continue; + if (entry.createdTimestamp < cutoff) continue; + if (!Array.isArray(entry.changes)) continue; + const removesJoinRole = entry.changes.some(c => c.key === '$remove' && Array.isArray(c.new) && c.new.some(r => roleIDs.includes(r.id))); + if (removesJoinRole) matches.push(entry); + } + return matches; +} + +/** + * Schedules a 5-second watchdog after a successful re-add so we can revert if a quarantine + * role appears post-grant (worst-case race that audit-log + holding-state checks couldn't catch). + * @private + * @param {Object} client + * @param {Object} member + * @param {string[]} grantedRoleIDs + */ +function startWatchdog(client, member, grantedRoleIDs) { + const quarantineRoleID = (moderationConfig(client, 'config') || {})['quarantine-role-id']; + if (!quarantineRoleID) return; + + const memberID = member.id; + if (watchdogTimers.has(memberID)) { + clearTimeout(watchdogTimers.get(memberID).timer); + } + const state = { + timer: setTimeout(() => { + watchdogTimers.delete(memberID); + }, WATCHDOG_MS), + quarantineRoleID, + grantedRoleIDs, + deadline: Date.now() + WATCHDOG_MS + }; + watchdogTimers.set(memberID, state); +} + +/** + * If a watchdog is active for this member and the new state shows the quarantine role appeared, + * remove the join roles we just re-added. + * @param {Object} client + * @param {Object} oldMember + * @param {Object} newMember + * @returns {Promise} + */ +async function checkWatchdog(client, oldMember, newMember) { + const state = watchdogTimers.get(newMember.id); + if (!state) return; + if (Date.now() > state.deadline) { + watchdogTimers.delete(newMember.id); + clearTimeout(state.timer); + return; + } + const hadQuarantine = oldMember.roles.cache.has(state.quarantineRoleID); + const hasQuarantine = newMember.roles.cache.has(state.quarantineRoleID); + if (!hadQuarantine && hasQuarantine) { + clearTimeout(state.timer); + watchdogTimers.delete(newMember.id); + const {localize} = require('../../src/functions/localize'); + client.logger.warn(localize('welcomer', 'base-role-watchdog-revert', {u: newMember.id})); + await newMember.roles.remove(state.grantedRoleIDs, localize('welcomer', 'base-role-audit-reason')).catch(() => { + }); + } +} + +/** + * Reacts to a guildMemberUpdate where one of the configured join roles was removed. Re-adds the + * role after a debounce, unless the member is in a holding state or the removal was bot-driven. + * @param {Object} client + * @param {Object} oldMember + * @param {Object} newMember + * @returns {Promise} + */ +async function handleRoleRemoval(client, oldMember, newMember) { + const welcomerConfig = client.configurations.welcomer.config; + if (!welcomerConfig['treat-welcome-roles-as-base-roles']) return; + const joinRoleIDs = welcomerConfig['give-roles-on-join'] || []; + if (joinRoleIDs.length === 0) return; + + const removed = joinRoleIDs.filter(id => oldMember.roles.cache.has(id) && !newMember.roles.cache.has(id)); + if (removed.length === 0) return; + + if (recentReadds.has(newMember.id)) return; + if (pendingDebounces.has(newMember.id)) return; + + if (await isInHoldingState(newMember, client)) return; + + const {localize} = require('../../src/functions/localize'); + const timer = setTimeout(async () => { + pendingDebounces.delete(newMember.id); + try { + const fresh = await newMember.guild.members.fetch({user: newMember.id, force: true}).catch(() => null); + if (!fresh) return; + + if (await isInHoldingState(fresh, client)) return; + + const stillMissing = joinRoleIDs.filter(id => !fresh.roles.cache.has(id)); + if (stillMissing.length === 0) return; + + const removalEntries = await fetchRecentJoinRoleRemovals(fresh.guild, fresh.id, joinRoleIDs); + if (removalEntries.some(e => e.executor && e.executor.id === client.user.id)) return; + + let actor = 'unknown'; + const attributable = removalEntries.find(e => e.executor); + if (attributable) { + const ex = attributable.executor; + actor = `${ex.tag || ex.username || ex.id} (${ex.id})`; + } + + await fresh.roles.add(stillMissing, localize('welcomer', 'base-role-audit-reason')); + recentReadds.add(fresh.id); + setTimeout(() => recentReadds.delete(fresh.id), LOOP_GUARD_MS); + startWatchdog(client, fresh, stillMissing); + + client.logger.info(localize('welcomer', 'base-role-re-added', { + u: fresh.id, + r: stillMissing.join(', '), + a: actor + })); + } catch (e) { + const sentryId = client.captureException ? client.captureException(e, { + module: 'welcomer', + phase: 'base-role-re-add', + userID: newMember.id + }) : null; + client.logger.error(localize('welcomer', 'assign-role-failed', { + u: newMember.id, + r: joinRoleIDs.join(', '), + e: (e && e.message) ? e.message : String(e) + }) + (sentryId ? ` [Sentry: ${sentryId}]` : '')); + } + }, DEBOUNCE_MS); + + pendingDebounces.set(newMember.id, timer); +} + +/** + * Returns the IDs of currently-configured holding roles (quarantine, JoinGate, anti-raid) that + * apply in this server. Only includes roles whose owning feature is enabled and uses `give-role`. + * @private + * @param {Object} client + * @returns {string[]} + */ +function getHoldingRoleIDs(client) { + const ids = []; + if (!moderationEnabled(client)) return ids; + const modConfig = moderationConfig(client, 'config'); + if (modConfig && modConfig['quarantine-role-id']) ids.push(modConfig['quarantine-role-id']); + const joinGate = moderationConfig(client, 'joinGate'); + if (joinGate && joinGate.enabled && joinGate.action === 'give-role' && joinGate.roleID) ids.push(joinGate.roleID); + const antiJoinRaid = moderationConfig(client, 'antiJoinRaid'); + if (antiJoinRaid && antiJoinRaid.enabled && antiJoinRaid.action === 'give-role' && antiJoinRaid.roleID) ids.push(antiJoinRaid.roleID); + return ids; +} + +/** + * Reacts to a guildMemberUpdate where a holding role (quarantine / JoinGate / anti-raid) was + * removed. If the member is no longer in any holding state and is missing join roles, grant them. + * @param {Object} client + * @param {Object} oldMember + * @param {Object} newMember + * @returns {Promise} + */ +async function handleHoldingRelease(client, oldMember, newMember) { + const welcomerConfig = client.configurations.welcomer.config; + if (!welcomerConfig['treat-welcome-roles-as-base-roles']) return; + const joinRoleIDs = welcomerConfig['give-roles-on-join'] || []; + if (joinRoleIDs.length === 0) return; + + const holdingIDs = getHoldingRoleIDs(client); + if (holdingIDs.length === 0) return; + + const released = holdingIDs.some(id => oldMember.roles.cache.has(id) && !newMember.roles.cache.has(id)); + if (!released) return; + + if (await isInHoldingState(newMember, client)) return; + + const missing = joinRoleIDs.filter(id => !newMember.roles.cache.has(id)); + if (missing.length === 0) return; + + const {localize} = require('../../src/functions/localize'); + try { + await newMember.roles.add(missing, localize('welcomer', 'base-role-audit-reason')); + client.logger.info(localize('welcomer', 'base-role-re-added', { + u: newMember.id, + r: missing.join(', '), + a: 'holding-release' + })); + } catch (e) { + const sentryId = client.captureException ? client.captureException(e, { + module: 'welcomer', + phase: 'base-role-holding-release', + userID: newMember.id + }) : null; + client.logger.error(localize('welcomer', 'assign-role-failed', { + u: newMember.id, + r: missing.join(', '), + e: (e && e.message) ? e.message : String(e) + }) + (sentryId ? ` [Sentry: ${sentryId}]` : '')); + } +} + +module.exports = { + isInHoldingState, + evaluateMember, + runSync, + handleRoleRemoval, + checkWatchdog, + handleHoldingRelease, + _state: {recentReadds, watchdogTimers, pendingDebounces} +}; diff --git a/modules/welcomer/configs/config.json b/modules/welcomer/configs/config.json index e53a6071..a8ee88ba 100644 --- a/modules/welcomer/configs/config.json +++ b/modules/welcomer/configs/config.json @@ -20,6 +20,14 @@ "type": "boolean", "category": "roles" }, + { + "name": "treat-welcome-roles-as-base-roles", + "humanName": "Treat join roles as base roles (auto-restore)", + "default": false, + "description": "Guarantees every regular member has all roles configured under 'Give roles on join'. When enabled, the bot re-adds a join role if it gets removed, grants missing join roles after a member is released from quarantine or Join Gate hold, and runs a daily sweep to catch anything missed. Quarantined, held, and pending members are excluded — see the docs for the full list of exclusions and edge cases.", + "type": "boolean", + "category": "roles" + }, { "name": "not-send-messages-if-member-is-bot", "humanName": "Ignore bots?", diff --git a/modules/welcomer/events/botReady.js b/modules/welcomer/events/botReady.js new file mode 100644 index 00000000..ef8ee0ca --- /dev/null +++ b/modules/welcomer/events/botReady.js @@ -0,0 +1,34 @@ +const schedule = require('node-schedule'); +const {runSync} = require('../baseRoles'); + +const INITIAL_DELAY_MS = 60_000; +const SCHEDULE_NAME = 'welcomer-base-role-sync'; +const SCHEDULE_CRON = '0 3 * * *'; + +module.exports.run = async (client) => { + const config = client.configurations.welcomer.config; + if (!config['treat-welcome-roles-as-base-roles']) return; + + setTimeout(() => { + runSync(client).catch(e => { + client.logger.error('[welcomer] Base-role initial sync failed: ' + (e && e.message ? e.message : String(e))); + if (client.captureException) client.captureException(e, { + module: 'welcomer', + phase: 'base-role-initial-sync' + }); + }); + }, INITIAL_DELAY_MS); + + if (schedule.scheduledJobs[SCHEDULE_NAME]) { + schedule.scheduledJobs[SCHEDULE_NAME].cancel(); + } + schedule.scheduleJob(SCHEDULE_NAME, SCHEDULE_CRON, () => { + runSync(client).catch(e => { + client.logger.error('[welcomer] Base-role daily sync failed: ' + (e && e.message ? e.message : String(e))); + if (client.captureException) client.captureException(e, { + module: 'welcomer', + phase: 'base-role-daily-sync' + }); + }); + }); +}; diff --git a/modules/welcomer/events/guildMemberAdd.js b/modules/welcomer/events/guildMemberAdd.js index 0371ed9c..f25844e9 100644 --- a/modules/welcomer/events/guildMemberAdd.js +++ b/modules/welcomer/events/guildMemberAdd.js @@ -93,10 +93,23 @@ module.exports.run = async function (client, guildMember) { function assignJoinRoles(guildMember, moduleConfig) { if (moduleConfig['give-roles-on-join'].length === 0) return; setTimeout(async () => { - if (!guildMember.doNotGiveWelcomeRole) { + if (guildMember.doNotGiveWelcomeRole) return; + const client = guildMember.client; + const roleIDs = moduleConfig['give-roles-on-join']; + try { const m = await guildMember.fetch(true); - m.roles.add(moduleConfig['give-roles-on-join']).then(() => { - }); + await m.roles.add(roleIDs, '[welcomer] ' + localize('welcomer', 'audit-log-reason-join-roles')); + } catch (e) { + const sentryId = client.captureException ? client.captureException(e, { + module: 'welcomer', + userID: guildMember.id, + roleIDs + }) : null; + client.logger.error(localize('welcomer', 'assign-role-failed', { + u: guildMember.id, + r: roleIDs.join(', '), + e: (e && e.message) ? e.message : String(e) + }) + (sentryId ? ` [Sentry: ${sentryId}]` : '')); } }, 500); } diff --git a/modules/welcomer/events/guildMemberUpdate.js b/modules/welcomer/events/guildMemberUpdate.js index 3643f079..794e1066 100644 --- a/modules/welcomer/events/guildMemberUpdate.js +++ b/modules/welcomer/events/guildMemberUpdate.js @@ -7,6 +7,7 @@ const { } = require('../../../src/functions/helpers'); const {localize} = require('../../../src/functions/localize'); const {assignJoinRoles} = require('./guildMemberAdd'); +const {handleRoleRemoval, handleHoldingRelease, checkWatchdog} = require('../baseRoles'); module.exports.run = async function (client, oldGuildMember, newGuildMember) { const moduleConfig = client.configurations['welcomer']['config']; @@ -16,6 +17,10 @@ module.exports.run = async function (client, oldGuildMember, newGuildMember) { if (newGuildMember.guild.id !== client.guild.id) return; + handleRoleRemoval(client, oldGuildMember, newGuildMember); + handleHoldingRelease(client, oldGuildMember, newGuildMember); + checkWatchdog(client, oldGuildMember, newGuildMember); + if (!oldGuildMember.premiumSince && newGuildMember.premiumSince) { await sendBoostMessage('boost'); } diff --git a/modules/welcomer/events/interactionCreate.js b/modules/welcomer/events/interactionCreate.js index 3d62e842..890c5d86 100644 --- a/modules/welcomer/events/interactionCreate.js +++ b/modules/welcomer/events/interactionCreate.js @@ -1,5 +1,6 @@ const {localize} = require('../../../src/functions/localize'); const {embedType, formatDiscordUserName} = require('../../../src/functions/helpers'); +const {ComponentType} = require('discord.js'); module.exports.run = async function (client, interaction) { if (!interaction.isButton()) return; @@ -20,7 +21,10 @@ module.exports.run = async function (client, interaction) { content: '⚠️ ' + localize('welcomer', 'channel-not-found', {c: channelConfig.sendChannel}) }); await interaction.update({ - components: interaction.message.components.filter(f => f.components[0].customId !== interaction.customId) + components: interaction.message.components.filter(f => { + if (f.type !== ComponentType.ActionRow) return true; + return !f.components.some(child => child.customId === interaction.customId); + }) }); const user = await client.users.fetch(userID); sendChannel.send(embedType(channelConfig['welcome-button-message'], { diff --git a/modules/welcomer/module.json b/modules/welcomer/module.json index c0e4b355..b88ad653 100644 --- a/modules/welcomer/module.json +++ b/modules/welcomer/module.json @@ -2,8 +2,8 @@ "name": "welcomer", "author": { "scnxOrgID": "1", - "name": "SCDerox (SC Network Team)", - "link": "https://github.com/SCDerox" + "name": "ScootKit Team (scootkit.com)", + "link": "https://github.com/ScootKit" }, "fa-icon": "fas fa-door-open", "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/welcomer", diff --git a/package-lock.json b/package-lock.json index 415f5651..e7544375 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,96 +9,64 @@ "version": "3.1.1", "license": "LicenseRef-LICENSE", "dependencies": { - "@androz2091/discord-invites-tracker": "1.1.1", - "@pixelfactory/privatebin": "2.6.1", "@scderox/ikea-name-generator": "1.0.0", - "@twurple/api": "5.3.4", - "@twurple/auth": "5.3.4", + "@twurple/api": "8.1.4", + "@twurple/auth": "8.1.4", "age-calculator": "1.0.0", - "bs58": "5.0.0", - "bufferutil": "4.0.7", - "centra": "2.6.0", - "discord-api-types": "0.38.37", - "discord-logs": "2.2.1", - "discord.js": "14.25.1", - "dotenv": "16.3.1", - "erlpack": "github:discord/erlpack", - "fparser": "3.1.0", - "fs-extra": "11.1.1", - "html-entities": "2.4.0", - "is-equal": "1.6.4", - "isomorphic-webcrypto": "2.3.8", - "jsonfile": "6.1.0", + "centra": "2.7.0", + "discord-api-types": "^0.38.47", + "discord.js": "14.26.4", + "fparser": "^4.2.0", + "is-equal": "^1.6.4", + "jsonfile": "6.2.1", "log4js": "6.9.1", "node-schedule": "2.1.1", - "parse-duration": "1.1.2", - "sequelize": "6.37.7", - "sqlite3": "5.1.7", - "stop-discord-phishing": "0.3.3", - "utf-8-validate": "6.0.3", - "zlib-sync": "0.1.8" + "parse-duration": "2.1.6", + "sequelize": "6.37.8", + "sqlite3": "6.0.1", + "umzug": "^3.8.3" }, "devDependencies": { - "eslint": "8.49.0" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@stylistic/eslint-plugin": "^5.6.1", + "eslint": "10.4.0", + "globals": "^17.6.0", + "jest": "^30.4.2" }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@androz2091/discord-invites-tracker": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@androz2091/discord-invites-tracker/-/discord-invites-tracker-1.1.1.tgz", - "integrity": "sha512-5oGwZNLnQcn+PMqtif84aCjbDdqCYvw0r8brRtlBDQV0HLwfLimD6XSo19HpTQY/1Z6dT1A9nEmvLHHvU8YEjw==" - }, - "node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/highlight": "^7.10.4" + "optionalDependencies": { + "bufferutil": "4.1.0", + "erlpack": "github:discord/erlpack", + "utf-8-validate": "6.0.6", + "zlib-sync": "0.1.10" } }, "node_modules/@babel/compat-data": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz", - "integrity": "sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==", - "optional": true, - "peer": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.22.19", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.19.tgz", - "integrity": "sha512-Q8Yj5X4LHVYTbLCKVz0//2D2aDmHF4xzCdEttYvKOnWvErGsa6geHXD6w46x64n5tP69VfeH+IfSrdyH3MLhwA==", - "optional": true, - "peer": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.22.15", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.22.19", - "@babel/helpers": "^7.22.15", - "@babel/parser": "^7.22.16", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.22.19", - "@babel/types": "^7.22.19", - "convert-source-map": "^1.7.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", @@ -113,159 +81,56 @@ } }, "node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", - "optional": true, - "peer": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.22.13", - "chalk": "^2.4.2" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/core/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/core/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/core/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "optional": true, - "peer": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/core/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "optional": true, - "peer": true - }, - "node_modules/@babel/core/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/core/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "optional": true, - "peer": true, + "dev": true, "bin": { "semver": "bin/semver.js" } }, - "node_modules/@babel/core/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/generator": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.5.tgz", - "integrity": "sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.24.5", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", - "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", - "optional": true, - "peer": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.15" + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", - "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", - "optional": true, - "peer": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.22.9", - "@babel/helper-validator-option": "^7.22.15", - "browserslist": "^4.21.9", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -277,56 +142,45 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "optional": true, - "peer": true, + "dev": true, "bin": { "semver": "bin/semver.js" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.5.tgz", - "integrity": "sha512-uRc4Cv8UQWnE4NXlYTIIdM7wfFkOqlFztcC/gVXDKohKoVB3OyonfelUBaJzSwpBntZ2KYGF/9S7asCHsXwW6g==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.24.5", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.24.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.24.5", - "semver": "^6.3.1" - }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", - "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", - "optional": true, - "peer": true, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "regexpu-core": "^5.3.1", - "semver": "^6.3.1" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -335,465 +189,263 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.2.tgz", - "integrity": "sha512-k0qnnOqHn5dK9pZpfD5XXZ9SojAITdCKRn2Lp6rnDGzIbaP0rHyMPk/4wsSxVBVz4RfN0q6VpXWP2pDGIoQ7hw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "optional": true, - "peer": true, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.5.tgz", - "integrity": "sha512-4owRteeihKWKamtqg4JmWSsEZU445xpFRXPEwp44HbgbxdWlUV1b4Agg4lkA806Lil5XM/e+FJyS0vj5T6vmcA==", - "optional": true, - "peer": true, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.24.5" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", - "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", - "optional": true, - "peer": true, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.24.0" + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.9.0" + "node": ">=6.0.0" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz", - "integrity": "sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.24.3", - "@babel/helper-simple-access": "^7.24.5", - "@babel/helper-split-export-declaration": "^7.24.5", - "@babel/helper-validator-identifier": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz", - "integrity": "sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.22.17", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.17.tgz", - "integrity": "sha512-bxH77R5gjH3Nkde6/LuncQoLaP16THYPscurp1S8z7S9ZgezCyV3G8Hc+TZiCmY8pz4fp8CvKSgtJMW0FkLAxA==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-wrap-function": "^7.22.17" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.1.tgz", - "integrity": "sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-simple-access": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz", - "integrity": "sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, "dependencies": { - "@babel/types": "^7.24.5" + "@babel/helper-plugin-utils": "^7.10.4" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", - "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.24.5" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.22.17", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.17.tgz", - "integrity": "sha512-nAhoheCMlrqU41tAojw9GpVEKDlTS8r3lzFmF0lP52LwblCPbuFSO7nGIZoIcoU5NIm1ABrna0cJExE4Ay6l2Q==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.15", - "@babel/types": "^7.22.17" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.5.tgz", - "integrity": "sha512-8lLmua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.5", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "optional": true, - "peer": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "optional": true, - "peer": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, "dependencies": { - "has-flag": "^3.0.0" + "@babel/helper-plugin-utils": "^7.10.4" }, - "engines": { - "node": ">=4" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=6.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.15.tgz", - "integrity": "sha512-FB9iYlz7rURmRJyXRKEnalYPPdn87H5no108cyuQQyMwlpJ2SJtpIUBI27kdTin956pz+LPypkPVPUTlxOmrsg==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.15.tgz", - "integrity": "sha512-Hyph9LseGvAeeXzikV88bczhsrLrIZqDPxO+sSmAunMPaGrBGhfMWzCPYTtiW9t+HzSE2wtV8e5cc5P6r1xMDQ==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.22.15" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { - "@babel/core": "^7.13.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-async-generator-functions": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.7.tgz", - "integrity": "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead.", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.18.9", - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/helper-remap-async-to-generator": "^7.18.9", - "@babel/plugin-syntax-async-generators": "^7.8.4" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-class-properties": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", - "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.24.1.tgz", - "integrity": "sha512-zPEvzFijn+hRvJuX2Vu3KbEBN39LN3f7tW3MQO2LsIs57B26KU+kUc82BdAktS1VCM6libzh45eKGI65lg0cpA==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-decorators": "^7.24.1" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { "node": ">=6.9.0" @@ -802,15 +454,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-export-default-from": { - "version": "7.22.17", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.22.17.tgz", - "integrity": "sha512-cop/3quQBVvdz6X5SJC6AhUv3C9DrVTM06LUEXimEdWAhCSyOJIr9NiZDU9leHZ0/aiG0Sh7Zmvaku5TWYNgbA==", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-export-default-from": "^7.22.5" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { "node": ">=6.9.0" @@ -819,16 +469,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-logical-assignment-operators": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.20.7.tgz", - "integrity": "sha512-y7C7cZgpMIjWlKE5T7eJwp+tnRYM89HmRvWM5EQuB5BoHEONjmQ8lSNmBUwOyy/GFRsohJED51YBF79hE1djug==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-logical-assignment-operators instead.", - "optional": true, - "peer": true, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -837,1707 +485,1542 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", - "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead.", - "optional": true, - "peer": true, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-numeric-separator": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", - "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-numeric-separator instead.", - "optional": true, - "peer": true, + "node_modules/@babel/template/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6", - "@babel/plugin-syntax-numeric-separator": "^7.10.4" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-object-rest-spread": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", - "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.", - "optional": true, - "peer": true, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.20.5", - "@babel/helper-compilation-targets": "^7.20.7", - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.20.7" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-optional-catch-binding": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz", - "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-catch-binding instead.", - "optional": true, - "peer": true, + "node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-optional-chaining": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", - "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead.", - "optional": true, - "peer": true, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "optional": true, - "peer": true, + "node_modules/@d-fischer/cache-decorators": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@d-fischer/cache-decorators/-/cache-decorators-4.0.1.tgz", + "integrity": "sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@d-fischer/shared-utils": "^3.6.3", + "tslib": "^2.6.2" } }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "optional": true, - "peer": true, + "node_modules/@d-fischer/detect-node": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@d-fischer/detect-node/-/detect-node-3.0.1.tgz", + "integrity": "sha512-0Rf3XwTzuTh8+oPZW9SfxTIiL+26RRJ0BRPwj5oVjZFyFKmsj9RGfN2zuTRjOuA3FCK/jYm06HOhwNK+8Pfv8w==", + "license": "MIT" + }, + "node_modules/@d-fischer/logger": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@d-fischer/logger/-/logger-4.2.4.tgz", + "integrity": "sha512-TFMZ/SVW8xyQtyJw9Rcuci4betSKy0qbQn2B5+1+72vVXeO8Qb1pYvuwF5qr0vDGundmSWq7W8r19nVPnXXSvA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" + "@d-fischer/detect-node": "^3.0.1", + "@d-fischer/shared-utils": "^3.6.1", + "tslib": "^2.5.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/d-fischer" } }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "optional": true, - "peer": true, + "node_modules/@d-fischer/rate-limiter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@d-fischer/rate-limiter/-/rate-limiter-1.1.0.tgz", + "integrity": "sha512-O5HgACwApyCZhp4JTEBEtbv/W3eAwEkrARFvgWnEsDmXgCMWjIHwohWoHre5BW6IYXFSHBGsuZB/EvNL3942kQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" + "@d-fischer/logger": "^4.2.3", + "@d-fischer/shared-utils": "^3.6.3", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/d-fischer" } }, - "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.24.1.tgz", - "integrity": "sha512-05RJdO/cCrtVWuAaSn1tS3bH8jbsJa/Y1uD186u6J4C/1mnHFxseeuWpsqr9anvo7TUulev7tm7GDwRV+VuhDw==", - "optional": true, - "peer": true, + "node_modules/@d-fischer/shared-utils": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@d-fischer/shared-utils/-/shared-utils-3.6.4.tgz", + "integrity": "sha512-BPkVLHfn2Lbyo/ENDBwtEB8JVQ+9OzkjJhUunLaxkw4k59YFlQxUUwlDBejVSFcpQT0t+D3CQlX+ySZnQj0wxw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" + "tslib": "^2.4.1" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/d-fischer" } }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "optional": true, - "peer": true, + "node_modules/@d-fischer/typed-event-emitter": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@d-fischer/typed-event-emitter/-/typed-event-emitter-3.3.3.tgz", + "integrity": "sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "tslib": "^2.4.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/d-fischer" } }, - "node_modules/@babel/plugin-syntax-export-default-from": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.22.5.tgz", - "integrity": "sha512-ODAqWWXB/yReh/jVQDag/3/tl6lgBueQkk/TcfW/59Oykm4c8a55XloX0CTk2k2VJiFWMgHby9xNX29IbCv9dQ==", - "optional": true, - "peer": true, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.11.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/@babel/plugin-syntax-export-namespace-from": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", - "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "engines": { + "node": ">=16.11.0" } }, - "node_modules/@babel/plugin-syntax-flow": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.22.5.tgz", - "integrity": "sha512-9RdCl0i+q0QExayk2nOS7853w08yLucnnPML6EN9S8fgMPVtdLDCdx/cOQ/i44Lb9UeQX9A35yaqBBOMMZxPxQ==", - "optional": true, - "peer": true, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "discord-api-types": "^0.38.33" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.11.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz", - "integrity": "sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==", - "optional": true, - "peer": true, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=18" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz", - "integrity": "sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", "engines": { - "node": ">=6.9.0" + "node": ">=18" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" } }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "optional": true, - "peer": true, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "discord-api-types": "^0.38.33" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz", - "integrity": "sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==", - "optional": true, - "peer": true, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.11.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "engines": { + "node": ">=18" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", "optional": true, - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", "optional": true, - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "tslib": "^2.4.0" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", "optional": true, - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "tslib": "^2.4.0" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "optional": true, - "peer": true, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "optional": true, - "peer": true, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "18 || 20 || >=22" } }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz", - "integrity": "sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==", - "optional": true, - "peer": true, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "18 || 20 || >=22" } }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "optional": true, - "peer": true, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=6.9.0" + "node": "18 || 20 || >=22" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz", - "integrity": "sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==", - "optional": true, - "peer": true, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@eslint/core": "^1.2.1" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.15.tgz", - "integrity": "sha512-jBm1Es25Y+tVoTi5rfd5t1KLmL8ogLKpXszboWOTTtGFGz2RKnQe2yn7HbZ+kb/B8N0FVSGQo874NSlOU1T4+w==", - "optional": true, - "peer": true, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.9", - "@babel/plugin-syntax-async-generators": "^7.8.4" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz", - "integrity": "sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.5" - }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz", - "integrity": "sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==", - "optional": true, - "peer": true, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.15.tgz", - "integrity": "sha512-G1czpdJBZCtngoK1sJgloLiOHUnkb/bLZwqVZD8kXmq0ZnVfTTWUcs9OWtp0mBtYJ+4LQY1fllqBkOIPhXmFmw==", - "optional": true, - "peer": true, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@humanfs/types": "^0.15.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18.18.0" } }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz", - "integrity": "sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==", - "optional": true, - "peer": true, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18.18.0" } }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.11.tgz", - "integrity": "sha512-GMM8gGmqI7guS/llMFk1bJDkKfn3v3C4KHK9Yg1ey5qcHcOlKb0QvcMrgzvxo+T03/4szNh5lghY+fEC98Kq9g==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.11", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-class-static-block": "^7.14.5" - }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" + "node": ">=18.18.0" } }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.15.tgz", - "integrity": "sha512-VbbC3PGjBdE0wAWDdHM9G8Gm977pnYI0XpqMd6LrKISj8/DJXEsWqgRuTYaNE9Bv0JGhTZUzHDlMk18IpOuoqw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.9", - "@babel/helper-split-export-declaration": "^7.22.6", - "globals": "^11.1.0" - }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "engines": { - "node": ">=6.9.0" + "node": ">=12.22" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@babel/plugin-transform-classes/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "optional": true, - "peer": true, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=4" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz", - "integrity": "sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==", - "optional": true, - "peer": true, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/template": "^7.22.5" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=12" } }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.15.tgz", - "integrity": "sha512-HzG8sFl1ZVGTme74Nw+X01XsUTqERVQ6/RLHo3XjGRzm7XD6QTtfS3NJotVgCGy8BzkDqRjRBD8dAyJn5TuvSQ==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz", - "integrity": "sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" - }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz", - "integrity": "sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==", - "optional": true, - "peer": true, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.11.tgz", - "integrity": "sha512-g/21plo58sfteWjaO0ZNVb+uEOkJNjAaHhbejrnBmu011l/eNDScmkbjCC3l4FKb10ViaGU4aOkFznSu2zRHgA==", - "optional": true, - "peer": true, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-dynamic-import": "^7.8.3" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz", - "integrity": "sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==", - "optional": true, - "peer": true, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.11.tgz", - "integrity": "sha512-xa7aad7q7OiT8oNZ1mU7NrISjlSkVdMbNxn9IuLZyL9AJEhs1Apba3I+u5riX1dIkdptP5EKDG5XDPByWxtehw==", - "optional": true, - "peer": true, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + "minipass": "^7.0.4" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18.0.0" } }, - "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.22.5.tgz", - "integrity": "sha512-tujNbZdxdG0/54g/oua8ISToaXTFBf8EnSb5PgQSciIXWOWKX3S4+JR7ZE9ol8FZwf9kxitzkGQ+QWeov/mCiA==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-flow": "^7.22.5" - }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.15.tgz", - "integrity": "sha512-me6VGeHsx30+xh9fbDLLPi0J1HzmeIIyenoOQHuw2D4m2SAU3NrspX5XxJLBpqn5yrLzrlw2Iy3RA//Bx27iOA==", - "optional": true, - "peer": true, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=8" } }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz", - "integrity": "sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==", - "optional": true, - "peer": true, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" - }, + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=6" } }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.11.tgz", - "integrity": "sha512-CxT5tCqpA9/jXFlme9xIBCc5RPtdDq3JpkkhgHQqtDdiTnTI0jtZ0QzXhr5DILeYifDPp2wvY2ad+7+hLMW5Pw==", - "optional": true, - "peer": true, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-json-strings": "^7.8.3" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=8" } }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz", - "integrity": "sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==", - "optional": true, - "peer": true, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.11.tgz", - "integrity": "sha512-qQwRTP4+6xFCDV5k7gZBF3C31K34ut0tbEcTKxlX/0KXxm9GLcO14p570aWxFvVzx6QAfPgq7gaeIHXJC8LswQ==", - "optional": true, - "peer": true, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + "p-locate": "^4.1.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=8" } }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz", - "integrity": "sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==", - "optional": true, - "peer": true, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "p-try": "^2.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=6" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.22.5.tgz", - "integrity": "sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ==", - "optional": true, - "peer": true, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "p-limit": "^2.2.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=8" } }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.1.tgz", - "integrity": "sha512-szog8fFTUxBfw0b98gEWPaEqF42ZUD/T3bkynW/wtgx2p/XCP55WEsb+VosKceRSd6njipdZvNogqdtI4Q0chw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-simple-access": "^7.22.5" - }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=8" } }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.11.tgz", - "integrity": "sha512-rIqHmHoMEOhI3VkVf5jQ15l539KrwhzqcBO6wdCNWPWc/JWt9ILNYNUssbRpeq0qWns8svuw8LnMNCvWBIJ8wA==", - "optional": true, - "peer": true, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-module-transforms": "^7.22.9", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz", - "integrity": "sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==", - "optional": true, - "peer": true, + "node_modules/@jest/console/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", - "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", - "optional": true, - "peer": true, + "node_modules/@jest/console/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz", - "integrity": "sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==", - "optional": true, - "peer": true, + "node_modules/@jest/console/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.11.tgz", - "integrity": "sha512-YZWOw4HxXrotb5xsjMJUDlLgcDXSfO9eCmdl1bgW4+/lAGdkjaEvOnQ4p5WKKdUgSzO39dgPl0pTnfxm0OAXcg==", - "optional": true, - "peer": true, + "node_modules/@jest/console/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/console/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@types/yargs-parser": "*" } }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.11.tgz", - "integrity": "sha512-3dzU4QGPsILdJbASKhF/V2TVP+gJya1PsueQCxIPCEcerqF21oEcrob4mzjsp2Py/1nLfF5m+xYNMDpmA8vffg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-numeric-separator": "^7.10.4" - }, + "node_modules/@jest/console/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=10" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.15.tgz", - "integrity": "sha512-fEB+I1+gAmfAyxZcX1+ZUwLeAuuf8VIg67CTznZE0MqVFumWkh8xWtn58I4dxdVf080wn7gzWoF8vndOViJe9Q==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.22.9", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.22.15" - }, + "node_modules/@jest/console/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=8" } }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz", - "integrity": "sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==", - "optional": true, - "peer": true, + "node_modules/@jest/console/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.5" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.11.tgz", - "integrity": "sha512-rli0WxesXUeCJnMYhzAglEjLWVDF6ahb45HuprcmQuLidBJFWjNnOzssk2kuc6e33FlLaiZhG/kUIzUMWdBKaQ==", - "optional": true, - "peer": true, + "node_modules/@jest/console/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.15.tgz", - "integrity": "sha512-ngQ2tBhq5vvSJw2Q2Z9i7ealNkpDMU0rGWnHPKqRZO0tzZ5tlaoz4hDvhXioOoaE0X2vfNss1djwg0DXlfu30A==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" - }, + "node_modules/@jest/console/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.15.tgz", - "integrity": "sha512-hjk7qKIqhyzhhUvRT683TYQOFa/4cQKwQy7ALvTpODswN40MljzNDa0YldevS6tGbxwaEKVn502JmY0dP7qEtQ==", - "optional": true, - "peer": true, + "node_modules/@jest/console/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz", - "integrity": "sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==", - "optional": true, - "peer": true, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.11.tgz", - "integrity": "sha512-sSCbqZDBKHetvjSwpyWzhuHkmW5RummxJBVbYLkGkaiTOWGxml7SXt0iWa03bzxFIx7wOj3g/ILRd0RcJKBeSQ==", - "optional": true, - "peer": true, + "node_modules/@jest/core/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.22.11", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz", - "integrity": "sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==", - "optional": true, - "peer": true, + "node_modules/@jest/core/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.24.1.tgz", - "integrity": "sha512-mvoQg2f9p2qlpDQRBC7M3c3XTr0k7cp/0+kFKKO/7Gtu0LSw16eKB+Fabe2bDT/UpsyasTBBkAnbdsLrkD5XMw==", - "optional": true, - "peer": true, + "node_modules/@jest/core/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", - "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", - "optional": true, - "peer": true, + "node_modules/@jest/core/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/core/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-jsx": "^7.23.3", - "@babel/types": "^7.23.4" - }, + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=10" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", - "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.22.5" - }, + "node_modules/@jest/core/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=8" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.22.5.tgz", - "integrity": "sha512-nTh2ogNUtxbiSbxaT4Ds6aXnXEipHweN9YRgOX/oNXdf0cCrGn/+2LozFa3lnPV5D90MkjhgckCPBrsoSc1a7g==", - "optional": true, - "peer": true, + "node_modules/@jest/core/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.22.5.tgz", - "integrity": "sha512-yIiRO6yobeEIaI0RTbIr8iAK9FcBHLtZq0S89ZPjDLQXBA4xvghaKqI0etp/tF3htTM0sazJKKLz9oEiGRtu7w==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, + "node_modules/@jest/core/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.1.tgz", - "integrity": "sha512-+pWEAaDJvSm9aFvJNpLiM2+ktl2Sn2U5DdyiWdZBxmLc6+xGt88dvFqsHiAiDS+8WqUwbDfkKz9jRxK3M0k+kA==", - "optional": true, - "peer": true, + "node_modules/@jest/core/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-plugin-utils": "^7.24.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz", - "integrity": "sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw==", - "optional": true, - "peer": true, + "node_modules/@jest/core/node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "regenerator-transform": "^0.15.2" + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz", - "integrity": "sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, + "node_modules/@jest/core/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.15.tgz", - "integrity": "sha512-tEVLhk8NRZSmwQ0DJtxxhTrCht1HVo8VaMzYT4w6lwyKBuHsgoioAUA7/6eT2fRfc5/23fuGdlwIxXhRVgWr4g==", - "optional": true, - "peer": true, + "node_modules/@jest/core/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "babel-plugin-polyfill-corejs2": "^0.4.5", - "babel-plugin-polyfill-corejs3": "^0.8.3", - "babel-plugin-polyfill-regenerator": "^0.5.2", - "semver": "^6.3.1" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz", - "integrity": "sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==", - "optional": true, - "peer": true, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz", - "integrity": "sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==", - "optional": true, - "peer": true, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + "@jest/get-type": "30.1.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz", - "integrity": "sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz", - "integrity": "sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==", - "optional": true, - "peer": true, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz", - "integrity": "sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.5.tgz", - "integrity": "sha512-E0VWu/hk83BIFUWnsKZ4D81KXjN5L3MobvevOHErASk9IPwKHOkTgvqzvNo1yP/ePJWqqK2SpUR5z+KQbl6NVw==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.24.5", - "@babel/helper-plugin-utils": "^7.24.5", - "@babel/plugin-syntax-typescript": "^7.24.1" + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz", - "integrity": "sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz", - "integrity": "sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz", - "integrity": "sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz", - "integrity": "sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/globals/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@babel/preset-env": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.15.tgz", - "integrity": "sha512-tZFHr54GBkHk6hQuVA8w4Fmq+MSPsfvMG0vPnOYyTnJpyfMqybL8/MbNCPRT9zc2KBO2pe4tq15g6Uno4Jpoag==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.22.9", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.15", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.15", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.15", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.22.5", - "@babel/plugin-syntax-import-attributes": "^7.22.5", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.22.5", - "@babel/plugin-transform-async-generator-functions": "^7.22.15", - "@babel/plugin-transform-async-to-generator": "^7.22.5", - "@babel/plugin-transform-block-scoped-functions": "^7.22.5", - "@babel/plugin-transform-block-scoping": "^7.22.15", - "@babel/plugin-transform-class-properties": "^7.22.5", - "@babel/plugin-transform-class-static-block": "^7.22.11", - "@babel/plugin-transform-classes": "^7.22.15", - "@babel/plugin-transform-computed-properties": "^7.22.5", - "@babel/plugin-transform-destructuring": "^7.22.15", - "@babel/plugin-transform-dotall-regex": "^7.22.5", - "@babel/plugin-transform-duplicate-keys": "^7.22.5", - "@babel/plugin-transform-dynamic-import": "^7.22.11", - "@babel/plugin-transform-exponentiation-operator": "^7.22.5", - "@babel/plugin-transform-export-namespace-from": "^7.22.11", - "@babel/plugin-transform-for-of": "^7.22.15", - "@babel/plugin-transform-function-name": "^7.22.5", - "@babel/plugin-transform-json-strings": "^7.22.11", - "@babel/plugin-transform-literals": "^7.22.5", - "@babel/plugin-transform-logical-assignment-operators": "^7.22.11", - "@babel/plugin-transform-member-expression-literals": "^7.22.5", - "@babel/plugin-transform-modules-amd": "^7.22.5", - "@babel/plugin-transform-modules-commonjs": "^7.22.15", - "@babel/plugin-transform-modules-systemjs": "^7.22.11", - "@babel/plugin-transform-modules-umd": "^7.22.5", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.22.5", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11", - "@babel/plugin-transform-numeric-separator": "^7.22.11", - "@babel/plugin-transform-object-rest-spread": "^7.22.15", - "@babel/plugin-transform-object-super": "^7.22.5", - "@babel/plugin-transform-optional-catch-binding": "^7.22.11", - "@babel/plugin-transform-optional-chaining": "^7.22.15", - "@babel/plugin-transform-parameters": "^7.22.15", - "@babel/plugin-transform-private-methods": "^7.22.5", - "@babel/plugin-transform-private-property-in-object": "^7.22.11", - "@babel/plugin-transform-property-literals": "^7.22.5", - "@babel/plugin-transform-regenerator": "^7.22.10", - "@babel/plugin-transform-reserved-words": "^7.22.5", - "@babel/plugin-transform-shorthand-properties": "^7.22.5", - "@babel/plugin-transform-spread": "^7.22.5", - "@babel/plugin-transform-sticky-regex": "^7.22.5", - "@babel/plugin-transform-template-literals": "^7.22.5", - "@babel/plugin-transform-typeof-symbol": "^7.22.5", - "@babel/plugin-transform-unicode-escapes": "^7.22.10", - "@babel/plugin-transform-unicode-property-regex": "^7.22.5", - "@babel/plugin-transform-unicode-regex": "^7.22.5", - "@babel/plugin-transform-unicode-sets-regex": "^7.22.5", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "@babel/types": "^7.22.15", - "babel-plugin-polyfill-corejs2": "^0.4.5", - "babel-plugin-polyfill-corejs3": "^0.8.3", - "babel-plugin-polyfill-regenerator": "^0.5.2", - "core-js-compat": "^3.31.0", - "semver": "^6.3.1" - }, + "node_modules/@jest/globals/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/globals/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=10" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" + "node_modules/@jest/globals/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/@babel/preset-flow": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.22.15.tgz", - "integrity": "sha512-dB5aIMqpkgbTfN5vDdTRPzjqtWiZcRESNR88QYnoPR+bmdYoluOzMX9tQerTv0XzSgZYctPfO1oc0N5zdog1ew==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.15", - "@babel/plugin-transform-flow-strip-types": "^7.22.5" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/preset-react": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.24.1.tgz", - "integrity": "sha512-eFa8up2/8cZXLIpkafhaADTXSnl7IsUFCYenRWrARBz0/qZwcT0RBXpys0LJU4+WfPoF2ZG6ew6s2V6izMCwRA==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-transform-react-display-name": "^7.24.1", - "@babel/plugin-transform-react-jsx": "^7.23.4", - "@babel/plugin-transform-react-jsx-development": "^7.22.5", - "@babel/plugin-transform-react-pure-annotations": "^7.24.1" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/preset-typescript": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.24.1.tgz", - "integrity": "sha512-1DBaMmRDpuYQBPWD8Pf/WEwCrtgRHxsZnP4mIy9G/X+hFfbI47Q2G4t1Paakld84+qsk2fSsUPMKg71jkoOOaQ==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-syntax-jsx": "^7.24.1", - "@babel/plugin-transform-modules-commonjs": "^7.24.1", - "@babel/plugin-transform-typescript": "^7.24.1" - }, + "node_modules/@jest/globals/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@babel/register": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.22.15.tgz", - "integrity": "sha512-V3Q3EqoQdn65RCgTLwauZaTfd1ShhwPmbBv+1dkZV/HpCGMKVyn6oFcRlI7RaKqiDQjX2Qd3AuoEguBgdjIKlg==", - "optional": true, - "peer": true, + "node_modules/@jest/globals/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "clone-deep": "^4.0.1", - "find-cache-dir": "^2.0.0", - "make-dir": "^2.1.0", - "pirates": "^4.0.5", - "source-map-support": "^0.5.16" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/register/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "optional": true, - "peer": true, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" + "@types/node": "*", + "jest-regex-util": "30.4.0" }, "engines": { - "node": ">=6" - } - }, - "node_modules/@babel/register/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", - "optional": true, - "peer": true - }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "optional": true, - "peer": true, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "optional": true, - "peer": true, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "optional": true, - "peer": true, + "node_modules/@jest/reporters/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -2545,842 +2028,721 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.5.tgz", - "integrity": "sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.24.5", - "@babel/parser": "^7.24.5", - "@babel/types": "^7.24.5", - "debug": "^4.3.1", - "globals": "^11.1.0" + "node_modules/@jest/reporters/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", - "optional": true, - "peer": true, + "node_modules/@jest/reporters/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.24.2", - "picocolors": "^1.0.0" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } + "node_modules/@jest/reporters/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "optional": true, - "peer": true, + "node_modules/@jest/reporters/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/reporters/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@d-fischer/cache-decorators": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@d-fischer/cache-decorators/-/cache-decorators-3.0.3.tgz", - "integrity": "sha512-JmM9OyZY+nNRRsW+bS3i+PSjmXiR3BCBiyHjjvpTWhS373xYtNdWbzxPDtKu2SWpE2lpnGP0QwINe3Uo5BBxDw==", + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", "dependencies": { - "@d-fischer/shared-utils": "^3.0.1", - "tslib": "^2.1.0" + "balanced-match": "^1.0.0" } }, - "node_modules/@d-fischer/cross-fetch": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@d-fischer/cross-fetch/-/cross-fetch-4.2.1.tgz", - "integrity": "sha512-/tvOWaOFBW2NyLCuJ0Tf2wFaEqZudT9osF/2A7/K4NU+g7MAQfOAEMUizKtg3TTrEfwWLjGic3oOBdbmR3WBKg==", - "dependencies": { - "node-fetch": "^2.6.11" + "node_modules/@jest/reporters/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/@d-fischer/detect-node": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@d-fischer/detect-node/-/detect-node-3.0.1.tgz", - "integrity": "sha512-0Rf3XwTzuTh8+oPZW9SfxTIiL+26RRJ0BRPwj5oVjZFyFKmsj9RGfN2zuTRjOuA3FCK/jYm06HOhwNK+8Pfv8w==" - }, - "node_modules/@d-fischer/logger": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@d-fischer/logger/-/logger-4.2.3.tgz", - "integrity": "sha512-mJUx9OgjrNVLQa4od/+bqnmD164VTCKnK5B4WOW8TX5y/3w2i58p+PMRE45gUuFjk2BVtOZUg55JQM3d619fdw==", + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", "dependencies": { - "@d-fischer/detect-node": "^3.0.1", - "@d-fischer/shared-utils": "^3.2.0", - "tslib": "^2.0.3" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { - "url": "https://github.com/sponsors/d-fischer" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@d-fischer/promise.allsettled": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@d-fischer/promise.allsettled/-/promise.allsettled-2.0.2.tgz", - "integrity": "sha512-xY0vYDwJYFe22MS5ccQ50N4Mcc2nQ8J4eWE5Y354IxZwW32O5uTT6mmhFSuVF6ZrKvzHOCIrK+9WqOR6TI3tcA==", + "node_modules/@jest/reporters/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", "dependencies": { - "array.prototype.map": "^1.0.3", - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.2", - "get-intrinsic": "^1.0.2", - "iterate-value": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - } - }, - "node_modules/@d-fischer/qs": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@d-fischer/qs/-/qs-7.0.2.tgz", - "integrity": "sha512-yAu3xDooiL+ef84Jo8nLjDjWBRk7RXk163Y6aTvRB7FauYd3spQD/dWvgT7R4CrN54Juhrrc3dMY7mc+jZGurQ==", "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@d-fischer/rate-limiter": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@d-fischer/rate-limiter/-/rate-limiter-0.6.2.tgz", - "integrity": "sha512-wgeDJuczBhhQ44E5O+phNIx74WAzOTcqJa8x+fJtDmGocyhQP+To2GumBfINB0Jao+MmRiqUPd4TPoUbe2yISg==", + "node_modules/@jest/reporters/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "@d-fischer/logger": "^4.0.0", - "@d-fischer/promise.allsettled": "^2.0.2", - "@d-fischer/shared-utils": "^3.2.0", - "@types/node": "^12.12.5", - "tslib": "^2.0.3" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@d-fischer/shared-utils": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/@d-fischer/shared-utils/-/shared-utils-3.6.3.tgz", - "integrity": "sha512-Lz+Qk1WJLVoeREOHPZcIDTHOoxecxMSG2sq+x1xWYCH1exqiMKMMx06pXdy15UzHG7ohvQRNXk2oHqZ9EOl9jQ==", + "node_modules/@jest/reporters/node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.4.1" + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@discordjs/builders": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", - "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", "dependencies": { - "@discordjs/formatters": "^0.6.2", - "@discordjs/util": "^1.2.0", - "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.33", - "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.4", - "tslib": "^2.6.3" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=16.11.0" + "node": ">=16 || 14 >=14.17" }, "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@discordjs/collection": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "node_modules/@jest/reporters/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16.11.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/@discordjs/formatters": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", - "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", - "dependencies": { - "discord-api-types": "^0.38.33" - }, + "node_modules/@jest/reporters/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=16.11.0" + "node": ">=12" }, "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "node_modules/@jest/reporters/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", - "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", - "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", - "tslib": "^2.6.3", - "undici": "6.21.3" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@discordjs/util": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", - "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "node_modules/@jest/reporters/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.33" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/@discordjs/ws": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", - "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", "dependencies": { - "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.1", - "@discordjs/util": "^1.1.0", - "@sapphire/async-queue": "^1.5.2", - "@types/ws": "^8.5.10", - "@vladfrangu/async_event_emitter": "^2.2.4", - "discord-api-types": "^0.38.1", - "tslib": "^2.6.2", - "ws": "^8.17.0" + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" }, "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "engines": { - "node": ">=18" + "node_modules/@jest/snapshot-utils/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@jest/snapshot-utils/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@jest/snapshot-utils/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "license": "MIT" + }, + "node_modules/@jest/snapshot-utils/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", "dev": true, + "license": "MIT", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@eslint/js": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.49.0.tgz", - "integrity": "sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==", + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@expo/bunyan": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@expo/bunyan/-/bunyan-4.0.0.tgz", - "integrity": "sha512-Ydf4LidRB/EBI+YrB+cVLqIseiRfjUI/AeHBgjGMtq3GroraDu81OV7zqophRgupngoL3iS3JUMDMnxO7g39qA==", - "engines": [ - "node >=0.10.0" - ], - "optional": true, - "peer": true, + "node_modules/@jest/test-result/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "uuid": "^8.0.0" + "@sinclair/typebox": "^0.34.0" }, - "optionalDependencies": { - "mv": "~2", - "safe-json-stringify": "~1" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@expo/cli": { - "version": "0.18.10", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.18.10.tgz", - "integrity": "sha512-cuAE060tcX4Mn+sF+tGAchGDsTNzwCUB7ioFGB3OrvxoU3idsqZJPs6xMt5Utuuy7QDGPnOn68H0vC4kDsXkUQ==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/runtime": "^7.20.0", - "@expo/code-signing-certificates": "0.0.5", - "@expo/config": "~9.0.0-beta.0", - "@expo/config-plugins": "~8.0.0-beta.0", - "@expo/devcert": "^1.0.0", - "@expo/env": "~0.3.0", - "@expo/image-utils": "^0.5.0", - "@expo/json-file": "^8.3.0", - "@expo/metro-config": "~0.18.0", - "@expo/osascript": "^2.0.31", - "@expo/package-manager": "^1.5.0", - "@expo/plist": "^0.1.0", - "@expo/prebuild-config": "7.0.3", - "@expo/rudder-sdk-node": "1.1.1", - "@expo/spawn-async": "^1.7.2", - "@expo/xcpretty": "^4.3.0", - "@react-native/dev-middleware": "~0.74.75", - "@urql/core": "2.3.6", - "@urql/exchange-retry": "0.3.0", - "accepts": "^1.3.8", - "arg": "5.0.2", - "better-opn": "~3.0.2", - "bplist-parser": "^0.3.1", - "cacache": "^15.3.0", - "chalk": "^4.0.0", - "ci-info": "^3.3.0", - "connect": "^3.7.0", - "debug": "^4.3.4", - "env-editor": "^0.4.1", - "fast-glob": "^3.3.2", - "find-yarn-workspace-root": "~2.0.0", - "form-data": "^3.0.1", - "freeport-async": "2.0.0", - "fs-extra": "~8.1.0", - "getenv": "^1.0.0", - "glob": "^7.1.7", - "graphql": "15.8.0", - "graphql-tag": "^2.10.1", - "https-proxy-agent": "^5.0.1", - "internal-ip": "4.3.0", - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1", - "js-yaml": "^3.13.1", - "json-schema-deref-sync": "^0.13.0", - "lodash.debounce": "^4.0.8", - "md5hex": "^1.0.0", - "minimatch": "^3.0.4", - "node-fetch": "^2.6.7", - "node-forge": "^1.3.1", - "npm-package-arg": "^7.0.0", - "open": "^8.3.0", - "ora": "3.4.0", - "picomatch": "^3.0.1", - "pretty-bytes": "5.6.0", - "progress": "2.0.3", - "prompts": "^2.3.2", - "qrcode-terminal": "0.11.0", - "require-from-string": "^2.0.2", - "requireg": "^0.2.2", - "resolve": "^1.22.2", - "resolve-from": "^5.0.0", - "resolve.exports": "^2.0.2", - "semver": "^7.6.0", - "send": "^0.18.0", - "slugify": "^1.3.4", - "source-map-support": "~0.5.21", - "stacktrace-parser": "^0.1.10", - "structured-headers": "^0.4.1", - "tar": "^6.0.5", - "temp-dir": "^2.0.0", - "tempy": "^0.7.1", - "terminal-link": "^2.1.1", - "text-table": "^0.2.0", - "url-join": "4.0.0", - "wrap-ansi": "^7.0.0", - "ws": "^8.12.1" + "node_modules/@jest/test-result/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, - "bin": { - "expo-internal": "build/bin/cli" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@expo/cli/node_modules/@expo/prebuild-config": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-7.0.3.tgz", - "integrity": "sha512-Kvxy/oQzkxwXLvAmwb+ygxuRn4xUUN2+mVJj3KDe4bRVCNyDPs7wlgdokF3twnWjzRZssUzseMkhp+yHPjAEhA==", - "optional": true, - "peer": true, - "dependencies": { - "@expo/config": "~9.0.0-beta.0", - "@expo/config-plugins": "~8.0.0-beta.0", - "@expo/config-types": "^51.0.0-unreleased", - "@expo/image-utils": "^0.5.0", - "@expo/json-file": "^8.3.0", - "@react-native/normalize-colors": "~0.74.83", - "debug": "^4.3.1", - "fs-extra": "^9.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "xml2js": "0.6.0" - }, - "peerDependencies": { - "expo-modules-autolinking": ">=0.8.1" + "node_modules/@jest/test-result/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/test-result/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" } }, - "node_modules/@expo/cli/node_modules/@expo/prebuild-config/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "optional": true, - "peer": true, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@expo/cli/node_modules/@react-native/normalize-colors": { - "version": "0.74.83", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.83.tgz", - "integrity": "sha512-jhCY95gRDE44qYawWVvhTjTplW1g+JtKTKM3f8xYT1dJtJ8QWv+gqEtKcfmOHfDkSDaMKG0AGBaDTSK8GXLH8Q==", - "optional": true, - "peer": true - }, - "node_modules/@expo/cli/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "optional": true, - "peer": true, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@expo/cli/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "optional": true, - "peer": true, + "node_modules/@jest/transform/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@expo/cli/node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", - "optional": true, - "peer": true, + "node_modules/@jest/transform/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "restore-cursor": "^2.0.0" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@expo/cli/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "optional": true, - "peer": true, - "dependencies": { - "color-name": "1.1.3" - } + "node_modules/@jest/transform/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/@expo/cli/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "optional": true, - "peer": true - }, - "node_modules/@expo/cli/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@expo/cli/node_modules/expo-modules-autolinking": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-1.11.1.tgz", - "integrity": "sha512-2dy3lTz76adOl7QUvbreMCrXyzUiF8lygI7iFJLjgIQIVH+43KnFWE5zBumpPbkiaq0f0uaFpN9U0RGQbnKiMw==", - "optional": true, - "peer": true, + "node_modules/@jest/transform/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "commander": "^7.2.0", - "fast-glob": "^3.2.5", - "find-up": "^5.0.0", - "fs-extra": "^9.1.0" - }, - "bin": { - "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + "@types/yargs-parser": "*" } }, - "node_modules/@expo/cli/node_modules/expo-modules-autolinking/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "optional": true, - "peer": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, + "node_modules/@jest/transform/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/@expo/cli/node_modules/form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", - "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", - "optional": true, - "peer": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.35" - }, + "node_modules/@jest/transform/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@expo/cli/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "optional": true, - "peer": true, + "node_modules/@jest/transform/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@expo/cli/node_modules/fs-extra/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "optional": true, - "peer": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@expo/cli/node_modules/fs-extra/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "optional": true, - "peer": true, + "node_modules/@jest/transform/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@expo/cli/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "optional": true, - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, + "node_modules/@jest/transform/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", "engines": { - "node": "*" + "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/cli/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "optional": true, - "peer": true, + "node_modules/@jest/transform/node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, "engines": { - "node": ">=4" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/@expo/cli/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "optional": true, - "peer": true, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@expo/cli/node_modules/log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", - "optional": true, - "peer": true, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^2.0.1" - }, + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, "engines": { - "node": ">=4" + "node": ">=6.0.0" } }, - "node_modules/@expo/cli/node_modules/log-symbols/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "optional": true, - "peer": true, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@expo/cli/node_modules/log-symbols/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", "optional": true, - "peer": true, "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "@tybys/wasm-util": "^0.10.1" }, - "engines": { - "node": ">=4" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@expo/cli/node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", "optional": true, - "peer": true, "engines": { - "node": ">=4" + "node": ">=14" } }, - "node_modules/@expo/cli/node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", - "optional": true, - "peer": true, - "dependencies": { - "mimic-fn": "^1.0.0" - }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" } }, - "node_modules/@expo/cli/node_modules/ora": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", - "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", - "optional": true, - "peer": true, + "node_modules/@rushstack/node-core-library": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-4.0.2.tgz", + "integrity": "sha512-hyES82QVpkfQMeBMteQUnrhASL/KHPhd7iJ8euduwNJG4mu2GSOKybf0rOEjOm1Wz7CwJEUm9y0yD7jg2C1bfg==", + "license": "MIT", "dependencies": { - "chalk": "^2.4.2", - "cli-cursor": "^2.1.0", - "cli-spinners": "^2.0.0", - "log-symbols": "^2.2.0", - "strip-ansi": "^5.2.0", - "wcwidth": "^1.0.1" + "fs-extra": "~7.0.1", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.5.4", + "z-schema": "~5.0.2" }, - "engines": { - "node": ">=6" + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@expo/cli/node_modules/ora/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "optional": true, - "peer": true, + "node_modules/@rushstack/node-core-library/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" }, "engines": { - "node": ">=4" + "node": ">=6 <7 || >=8" } }, - "node_modules/@expo/cli/node_modules/ora/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" + "node_modules/@rushstack/node-core-library/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/@expo/cli/node_modules/ora/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "optional": true, - "peer": true, + "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", "dependencies": { - "ansi-regex": "^4.1.0" + "yallist": "^4.0.0" }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@expo/cli/node_modules/picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", - "optional": true, - "peer": true, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@expo/cli/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" } }, - "node_modules/@expo/cli/node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", - "optional": true, - "peer": true, + "node_modules/@rushstack/node-core-library/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", "dependencies": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" + "lru-cache": "^6.0.0" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@expo/cli/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -3388,2606 +2750,758 @@ "node": ">=10" } }, - "node_modules/@expo/cli/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "optional": true, - "peer": true, + "node_modules/@rushstack/node-core-library/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@rushstack/terminal": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.10.0.tgz", + "integrity": "sha512-UbELbXnUdc7EKwfH2sb8ChqNgapUOdqcCIdQP4NGxBpTZV2sQyeekuK3zmfQSa/MN+/7b4kBogl2wq0vpkpYGw==", + "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "@rushstack/node-core-library": "4.0.2", + "supports-color": "~8.1.1" }, - "engines": { - "node": ">=4" + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@expo/cli/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "optional": true, - "peer": true, + "node_modules/@rushstack/terminal/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "has-flag": "^4.0.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/@expo/code-signing-certificates": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz", - "integrity": "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw==", - "optional": true, - "peer": true, + "node_modules/@rushstack/ts-command-line": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.19.1.tgz", + "integrity": "sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==", + "license": "MIT", "dependencies": { - "node-forge": "^1.2.1", - "nullthrows": "^1.1.1" + "@rushstack/terminal": "0.10.0", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" } }, - "node_modules/@expo/config": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-9.0.1.tgz", - "integrity": "sha512-0tjaXBstTbXmD4z+UMFBkh2SZFwilizSQhW6DlaTMnPG5ezuw93zSFEWAuEC3YzkpVtNQTmYzxAYjxwh6seOGg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~8.0.0-beta.0", - "@expo/config-types": "^51.0.0-unreleased", - "@expo/json-file": "^8.3.0", - "getenv": "^1.0.0", - "glob": "7.1.6", - "require-from-string": "^2.0.2", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4", - "sucrase": "3.34.0" - } - }, - "node_modules/@expo/config-plugins": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-8.0.4.tgz", - "integrity": "sha512-Hi+xuyNWE2LT4LVbGttHJgl9brnsdWAhEB42gWKb5+8ae86Nr/KwUBQJsJppirBYTeLjj5ZlY0glYnAkDa2jqw==", - "optional": true, - "peer": true, + "node_modules/@rushstack/ts-command-line/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", "dependencies": { - "@expo/config-types": "^51.0.0-unreleased", - "@expo/json-file": "~8.3.0", - "@expo/plist": "^0.1.0", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.1", - "find-up": "~5.0.0", - "getenv": "^1.0.0", - "glob": "7.1.6", - "resolve-from": "^5.0.0", - "semver": "^7.5.4", - "slash": "^3.0.0", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" + "sprintf-js": "~1.0.2" } }, - "node_modules/@expo/config-plugins/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "optional": true, - "peer": true, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", "engines": { - "node": ">=8" + "node": ">=v14.0.0", + "npm": ">=7.0.0" } }, - "node_modules/@expo/config-types": { - "version": "51.0.0", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-51.0.0.tgz", - "integrity": "sha512-acn03/u8mQvBhdTQtA7CNhevMltUhbSrpI01FYBJwpVntufkU++ncQujWKlgY/OwIajcfygk1AY4xcNZ5ImkRA==", - "optional": true, - "peer": true - }, - "node_modules/@expo/config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "optional": true, - "peer": true, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, "engines": { - "node": ">=8" + "node": ">=v16" } }, - "node_modules/@expo/config/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", "engines": { - "node": ">=10" + "node": ">=v14.0.0", + "npm": ">=7.0.0" } }, - "node_modules/@expo/devcert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", - "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", - "optional": true, - "peer": true, + "node_modules/@scderox/ikea-name-generator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@scderox/ikea-name-generator/-/ikea-name-generator-1.0.0.tgz", + "integrity": "sha512-tBeB6sfUR6ZzrwWDbjQejuij09+KAQAAI9eba8DKe+ZARDTgbaXhjMU25NyU0AL+qPgBy4Nm8ZPyncy8Ee8Abw==", "dependencies": { - "@expo/sudo-prompt": "^9.3.1", - "debug": "^3.1.0" + "compromise": "^13.11.2", + "nlp_compromise": "^4.12.0", + "nlp-syllables": "^0.0.5" } }, - "node_modules/@expo/devcert/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "optional": true, - "peer": true, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "ms": "^2.1.1" + "type-detect": "4.0.8" } }, - "node_modules/@expo/env": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-0.3.0.tgz", - "integrity": "sha512-OtB9XVHWaXidLbHvrVDeeXa09yvTl3+IQN884sO6PhIi2/StXfgSH/9zC7IvzrDB8kW3EBJ1PPLuCUJ2hxAT7Q==", - "optional": true, - "peer": true, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^1.0.0" - } - }, - "node_modules/@expo/env/node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/@expo/image-utils": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.5.1.tgz", - "integrity": "sha512-U/GsFfFox88lXULmFJ9Shfl2aQGcwoKPF7fawSCLixIKtMCpsI+1r0h+5i0nQnmt9tHuzXZDL8+Dg1z6OhkI9A==", - "optional": true, - "peer": true, - "dependencies": { - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "fs-extra": "9.0.0", - "getenv": "^1.0.0", - "jimp-compact": "0.16.1", - "node-fetch": "^2.6.0", - "parse-png": "^2.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "tempy": "0.3.0" - } - }, - "node_modules/@expo/image-utils/node_modules/crypto-random-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", - "integrity": "sha512-GsVpkFPlycH7/fRR7Dhcmnoii54gV1nz7y4CWyeFS14N+JVBBhY+r8amRHE4BwSYal7BPTDp8isvAlCxyFt3Hg==", - "optional": true, - "peer": true, "engines": { - "node": ">=4" - } - }, - "node_modules/@expo/image-utils/node_modules/fs-extra": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.0.tgz", - "integrity": "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==", - "optional": true, - "peer": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^1.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" } }, - "node_modules/@expo/image-utils/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "optional": true, - "peer": true, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/image-utils/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@expo/image-utils/node_modules/temp-dir": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==", - "optional": true, - "peer": true, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=4" - } - }, - "node_modules/@expo/image-utils/node_modules/tempy": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.3.0.tgz", - "integrity": "sha512-WrH/pui8YCwmeiAoxV+lpRH9HpRtgBhSR2ViBPgpGb/wnYDzp21R4MN45fsCGvLROvY67o3byhJRYRONJyImVQ==", - "optional": true, - "peer": true, - "dependencies": { - "temp-dir": "^1.0.0", - "type-fest": "^0.3.1", - "unique-string": "^1.0.0" + "node": ">=12" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/image-utils/node_modules/type-fest": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz", - "integrity": "sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@expo/image-utils/node_modules/unique-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", - "integrity": "sha512-ODgiYu03y5g76A1I9Gt0/chLCzQjvzDy7DsZGsLOE/1MrF6wriEskSncj1+/C58Xk/kPZDppSctDybCwOSaGAg==", - "optional": true, - "peer": true, + "node_modules/@twurple/api": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/@twurple/api/-/api-8.1.4.tgz", + "integrity": "sha512-UA2eg5lyZRB0w55NjGvdhSmWPyIBaOthFKGPjg3L/jCQVYnY/FcmUuPipe2Op9U4Ej99c29cdjsmTSQI7P7Vqg==", + "license": "MIT", "dependencies": { - "crypto-random-string": "^1.0.0" + "@d-fischer/cache-decorators": "^4.0.0", + "@d-fischer/detect-node": "^3.0.1", + "@d-fischer/logger": "^4.2.1", + "@d-fischer/rate-limiter": "^1.1.0", + "@d-fischer/shared-utils": "^3.6.1", + "@d-fischer/typed-event-emitter": "^3.3.3", + "@twurple/api-call": "8.1.4", + "@twurple/common": "8.1.4", + "retry": "^0.13.1", + "tslib": "^2.0.3" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@expo/image-utils/node_modules/universalify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", - "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10.0.0" + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "@twurple/auth": "8.1.4" } }, - "node_modules/@expo/json-file": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-8.3.3.tgz", - "integrity": "sha512-eZ5dld9AD0PrVRiIWpRkm5aIoWBw3kAyd8VkuWEy92sEthBKDDDHAnK2a0dw0Eil6j7rK7lS/Qaq/Zzngv2h5A==", - "optional": true, - "peer": true, + "node_modules/@twurple/api-call": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-8.1.4.tgz", + "integrity": "sha512-qh2TpdxxyiSkwadcCSes6uBHQB6l4Fz8sVfmzk+Brb12asemHMXTEyQAdrMJT7LlgtZq01nr+RASzWM3jmGtkw==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.2", - "write-file-atomic": "^2.3.0" - } - }, - "node_modules/@expo/metro-config": { - "version": "0.18.3", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-0.18.3.tgz", - "integrity": "sha512-E4iW+VT/xHPPv+t68dViOsW7egtGIr+sRElcym0iGpC4goLz9WBux/xGzWgxvgvvHEWa21uSZQPM0jWla0OZXg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@babel/parser": "^7.20.0", - "@babel/types": "^7.20.0", - "@expo/config": "~9.0.0-beta.0", - "@expo/env": "~0.3.0", - "@expo/json-file": "~8.3.0", - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "find-yarn-workspace-root": "~2.0.0", - "fs-extra": "^9.1.0", - "getenv": "^1.0.0", - "glob": "^7.2.3", - "jsc-safe-url": "^0.2.4", - "lightningcss": "~1.19.0", - "postcss": "~8.4.32", - "resolve-from": "^5.0.0" + "@d-fischer/shared-utils": "^3.6.1", + "@twurple/common": "8.1.4", + "tslib": "^2.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" } }, - "node_modules/@expo/metro-config/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "optional": true, - "peer": true, + "node_modules/@twurple/auth": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/@twurple/auth/-/auth-8.1.4.tgz", + "integrity": "sha512-ylsJoPInCw9BwOqxKcx+1k2ce9QG3vJpKFzPdIyHh49HvM/ulQZ0CAGysydugDYXF0iO/TGryh7PluSwx5fIwA==", + "license": "MIT", "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@d-fischer/logger": "^4.2.1", + "@d-fischer/shared-utils": "^3.6.1", + "@d-fischer/typed-event-emitter": "^3.3.3", + "@twurple/api-call": "8.1.4", + "@twurple/common": "8.1.4", + "tslib": "^2.0.3" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://github.com/sponsors/d-fischer" } }, - "node_modules/@expo/metro-config/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "optional": true, - "peer": true, + "node_modules/@twurple/common": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/@twurple/common/-/common-8.1.4.tgz", + "integrity": "sha512-1iN5DvOnW+g+Nl3OTI5zUJHgAfjmPCb50HpKsAFik6OYQEAHLsscQKgTOJ+KRuFBYepo/JkHsOWOmWhXxnK6lQ==", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" + "@d-fischer/shared-utils": "^3.6.1", + "klona": "^2.0.4", + "tslib": "^2.0.3" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@expo/metro-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/d-fischer" } }, - "node_modules/@expo/osascript": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.1.2.tgz", - "integrity": "sha512-/ugqDG+52uzUiEpggS9GPdp9g0U9EQrXcTdluHDmnlGmR2nV/F83L7c+HCUyPnf77QXwkr8gQk16vQTbxBQ5eA==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", "optional": true, - "peer": true, "dependencies": { - "@expo/spawn-async": "^1.7.2", - "exec-async": "^2.2.0" - }, - "engines": { - "node": ">=12" + "tslib": "^2.4.0" } }, - "node_modules/@expo/package-manager": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.5.2.tgz", - "integrity": "sha512-IuA9XtGBilce0q8cyxtWINqbzMB1Fia0Yrug/O53HNuRSwQguV/iqjV68bsa4z8mYerePhcFgtvISWLAlNEbUA==", - "optional": true, - "peer": true, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", "dependencies": { - "@expo/json-file": "^8.3.0", - "@expo/spawn-async": "^1.7.2", - "ansi-regex": "^5.0.0", - "chalk": "^4.0.0", - "find-up": "^5.0.0", - "find-yarn-workspace-root": "~2.0.0", - "js-yaml": "^3.13.1", - "micromatch": "^4.0.2", - "npm-package-arg": "^7.0.0", - "ora": "^3.4.0", - "split": "^1.0.1", - "sudo-prompt": "9.1.1" + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "node_modules/@expo/package-manager/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "optional": true, - "peer": true, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" + "@babel/types": "^7.0.0" } }, - "node_modules/@expo/package-manager/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "optional": true, - "peer": true, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, - "node_modules/@expo/package-manager/node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", - "optional": true, - "peer": true, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", "dependencies": { - "restore-cursor": "^2.0.0" - }, - "engines": { - "node": ">=4" + "@babel/types": "^7.28.2" } }, - "node_modules/@expo/package-manager/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "optional": true, - "peer": true, + "node_modules/@types/debug": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", + "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==", "dependencies": { - "color-name": "1.1.3" + "@types/ms": "*" } }, - "node_modules/@expo/package-manager/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "optional": true, - "peer": true + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" }, - "node_modules/@expo/package-manager/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.8.0" - } + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" }, - "node_modules/@expo/package-manager/node_modules/has-flag": { + "node_modules/@types/istanbul-lib-report": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" } }, - "node_modules/@expo/package-manager/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "optional": true, - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@expo/package-manager/node_modules/log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", - "optional": true, - "peer": true, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^2.0.1" - }, - "engines": { - "node": ">=4" + "@types/istanbul-lib-report": "*" } }, - "node_modules/@expo/package-manager/node_modules/log-symbols/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" }, - "node_modules/@expo/package-manager/node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } + "node_modules/@types/ms": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", + "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==" }, - "node_modules/@expo/package-manager/node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", - "optional": true, - "peer": true, - "dependencies": { - "mimic-fn": "^1.0.0" - }, - "engines": { - "node": ">=4" - } + "node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" }, - "node_modules/@expo/package-manager/node_modules/ora": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", - "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", - "optional": true, - "peer": true, - "dependencies": { - "chalk": "^2.4.2", - "cli-cursor": "^2.1.0", - "cli-spinners": "^2.0.0", - "log-symbols": "^2.2.0", - "strip-ansi": "^5.2.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=6" - } + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" }, - "node_modules/@expo/package-manager/node_modules/ora/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } + "node_modules/@types/validator": { + "version": "13.11.1", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.1.tgz", + "integrity": "sha512-d/MUkJYdOeKycmm75Arql4M5+UuXmf4cHdHKsyw1GcvnNgL6s77UkgSgJ8TE/rI5PYsnwYq5jkcWBLuN/MpQ1A==" }, - "node_modules/@expo/package-manager/node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", - "optional": true, - "peer": true, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dependencies": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=4" + "@types/node": "*" } }, - "node_modules/@expo/package-manager/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } + "node_modules/@types/yargs-parser": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", + "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", + "dev": true }, - "node_modules/@expo/package-manager/node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "optional": true, - "peer": true, + "node_modules/@typescript-eslint/types": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6" - } - }, - "node_modules/@expo/package-manager/node_modules/sudo-prompt": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.1.1.tgz", - "integrity": "sha512-es33J1g2HjMpyAhz8lOR+ICmXXAqTuKbuXuUWLhOLew20oN9oUCgCJx615U/v7aioZg7IX5lIh9x34vwneu4pA==", - "optional": true, - "peer": true - }, - "node_modules/@expo/package-manager/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^3.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": ">=4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@expo/plist": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.1.3.tgz", - "integrity": "sha512-GW/7hVlAylYg1tUrEASclw1MMk9FP4ZwyFAY/SUTJIhPDQHtfOlXREyWV3hhrHdX/K+pS73GNgdfT6E/e+kBbg==", - "optional": true, - "peer": true, - "dependencies": { - "@xmldom/xmldom": "~0.7.7", - "base64-js": "^1.2.3", - "xmlbuilder": "^14.0.0" - } + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" }, - "node_modules/@expo/rudder-sdk-node": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@expo/rudder-sdk-node/-/rudder-sdk-node-1.1.1.tgz", - "integrity": "sha512-uy/hS/awclDJ1S88w9UGpc6Nm9XnNUjzOAAib1A3PVAnGQIwebg8DpFqOthFBTlZxeuV/BKbZ5jmTbtNZkp1WQ==", + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "@expo/bunyan": "^4.0.0", - "@segment/loosely-validate-event": "^2.0.0", - "fetch-retry": "^4.1.1", - "md5": "^2.2.1", - "node-fetch": "^2.6.1", - "remove-trailing-slash": "^0.1.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">=12" - } + "os": [ + "android" + ] }, - "node_modules/@expo/sdk-runtime-versions": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", - "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true + "os": [ + "android" + ] }, - "node_modules/@expo/spawn-async": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", - "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3" - }, - "engines": { - "node": ">=12" - } + "os": [ + "darwin" + ] }, - "node_modules/@expo/sudo-prompt": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", - "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true + "os": [ + "darwin" + ] }, - "node_modules/@expo/vector-icons": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-14.0.1.tgz", - "integrity": "sha512-7oIe1RRWmRQXNxmewsuAaIRNAQfkig7EFTuI5T8PCI7T4q/rS5iXWvlzAEXndkzSOSs7BAANrLyj7AtpEhTksg==", + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "prop-types": "^15.8.1" - } + "os": [ + "freebsd" + ] }, - "node_modules/@expo/xcpretty": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.3.1.tgz", - "integrity": "sha512-sqXgo1SCv+j4VtYEwl/bukuOIBrVgx6euIoCat3Iyx5oeoXwEA2USCoeL0IPubflMxncA2INkqJ/Wr3NGrSgzw==", + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "7.10.4", - "chalk": "^4.1.0", - "find-up": "^5.0.0", - "js-yaml": "^4.1.0" - }, - "bin": { - "excpretty": "build/cli.js" - } - }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "optional": true + "os": [ + "linux" + ] }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } + "os": [ + "linux" + ] }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, - "peer": true + "os": [ + "linux" + ] }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" - } + "os": [ + "linux" + ] }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "deprecated": "Use @eslint/config-array instead", + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true - }, - "node_modules/@isaacs/ttlcache": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", - "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } + "os": [ + "linux" + ] }, - "node_modules/@jest/create-cache-key-function": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", - "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/create-cache-key-function/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/create-cache-key-function/node_modules/@types/yargs": { - "version": "17.0.24", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", - "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", - "optional": true, - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/environment/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/environment/node_modules/@types/yargs": { - "version": "17.0.24", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", - "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", - "optional": true, - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers/node_modules/@types/yargs": { - "version": "17.0.24", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", - "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", - "optional": true, - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "optional": true, - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", - "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", - "optional": true, - "peer": true, - "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^15.0.0", - "chalk": "^4.0.0" - }, - "engines": { - "node": ">= 10.14.2" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "optional": true, - "peer": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "devOptional": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "devOptional": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "devOptional": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "optional": true, - "dependencies": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "optional": true, - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/move-file/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "optional": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz", - "integrity": "sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==", - "dependencies": { - "asn1js": "^3.0.5", - "pvtsutils": "^1.3.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@peculiar/webcrypto": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.4.3.tgz", - "integrity": "sha512-VtaY4spKTdN5LjJ04im/d/joXuvLbQdgy5Z4DXF4MFZhQ+MTrejbNMkfZBp1Bs3O5+bFqnJgyGdPuZQflvIa5A==", - "dependencies": { - "@peculiar/asn1-schema": "^2.3.6", - "@peculiar/json-schema": "^1.1.12", - "pvtsutils": "^1.3.2", - "tslib": "^2.5.0", - "webcrypto-core": "^1.7.7" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/@pixelfactory/privatebin": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@pixelfactory/privatebin/-/privatebin-2.6.1.tgz", - "integrity": "sha512-qMPaq6pONB6Xmqpb2PRcmCgfl4MCbdoVMiLzUztLdWStUAP6fKcvtZyYCQYkDvy3oT3oxwPaTxoWDY1f9Tb6PQ==", - "dependencies": { - "axios": "^0.21.1", - "bs58": "^4.0.1", - "byte-base64": "^1.1.0", - "chalk": "^4.1.0", - "commander": "^7.1.0", - "inquirer": "^8.0.0", - "isomorphic-webcrypto": "^2.3.8", - "pako": "^2.0.3", - "pjson": "^1.0.9", - "yaml": "^1.10.0" - }, - "bin": { - "privatebin": "dist/bin/privatebin.js" - } - }, - "node_modules/@pixelfactory/privatebin/node_modules/base-x": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", - "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@pixelfactory/privatebin/node_modules/bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", - "dependencies": { - "base-x": "^3.0.2" - } - }, - "node_modules/@react-native-community/cli": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-11.3.6.tgz", - "integrity": "sha512-bdwOIYTBVQ9VK34dsf6t3u6vOUU5lfdhKaAxiAVArjsr7Je88Bgs4sAbsOYsNK3tkE8G77U6wLpekknXcanlww==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native-community/cli-clean": "11.3.6", - "@react-native-community/cli-config": "11.3.6", - "@react-native-community/cli-debugger-ui": "11.3.6", - "@react-native-community/cli-doctor": "11.3.6", - "@react-native-community/cli-hermes": "11.3.6", - "@react-native-community/cli-plugin-metro": "11.3.6", - "@react-native-community/cli-server-api": "11.3.6", - "@react-native-community/cli-tools": "11.3.6", - "@react-native-community/cli-types": "11.3.6", - "chalk": "^4.1.2", - "commander": "^9.4.1", - "execa": "^5.0.0", - "find-up": "^4.1.0", - "fs-extra": "^8.1.0", - "graceful-fs": "^4.1.3", - "prompts": "^2.4.0", - "semver": "^7.5.2" - }, - "bin": { - "react-native": "build/bin.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@react-native-community/cli-clean": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-11.3.6.tgz", - "integrity": "sha512-jOOaeG5ebSXTHweq1NznVJVAFKtTFWL4lWgUXl845bCGX7t1lL8xQNWHKwT8Oh1pGR2CI3cKmRjY4hBg+pEI9g==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native-community/cli-tools": "11.3.6", - "chalk": "^4.1.2", - "execa": "^5.0.0", - "prompts": "^2.4.0" - } - }, - "node_modules/@react-native-community/cli-clean/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "optional": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@react-native-community/cli-clean/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-clean/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-clean/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "optional": true, - "peer": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-config": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-11.3.6.tgz", - "integrity": "sha512-edy7fwllSFLan/6BG6/rznOBCLPrjmJAE10FzkEqNLHowi0bckiAPg1+1jlgQ2qqAxV5kuk+c9eajVfQvPLYDA==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native-community/cli-tools": "11.3.6", - "chalk": "^4.1.2", - "cosmiconfig": "^5.1.0", - "deepmerge": "^4.3.0", - "glob": "^7.1.3", - "joi": "^17.2.1" - } - }, - "node_modules/@react-native-community/cli-debugger-ui": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-debugger-ui/-/cli-debugger-ui-11.3.6.tgz", - "integrity": "sha512-jhMOSN/iOlid9jn/A2/uf7HbC3u7+lGktpeGSLnHNw21iahFBzcpuO71ekEdlmTZ4zC/WyxBXw9j2ka33T358w==", - "optional": true, - "peer": true, - "dependencies": { - "serve-static": "^1.13.1" - } - }, - "node_modules/@react-native-community/cli-doctor": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-11.3.6.tgz", - "integrity": "sha512-UT/Tt6omVPi1j6JEX+CObc85eVFghSZwy4GR9JFMsO7gNg2Tvcu1RGWlUkrbmWMAMHw127LUu6TGK66Ugu1NLA==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native-community/cli-config": "11.3.6", - "@react-native-community/cli-platform-android": "11.3.6", - "@react-native-community/cli-platform-ios": "11.3.6", - "@react-native-community/cli-tools": "11.3.6", - "chalk": "^4.1.2", - "command-exists": "^1.2.8", - "envinfo": "^7.7.2", - "execa": "^5.0.0", - "hermes-profile-transformer": "^0.0.6", - "ip": "^1.1.5", - "node-stream-zip": "^1.9.1", - "ora": "^5.4.1", - "prompts": "^2.4.0", - "semver": "^7.5.2", - "strip-ansi": "^5.2.0", - "sudo-prompt": "^9.0.0", - "wcwidth": "^1.0.1", - "yaml": "^2.2.1" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "optional": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "optional": true, - "peer": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/sudo-prompt": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", - "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", - "optional": true, - "peer": true - }, - "node_modules/@react-native-community/cli-doctor/node_modules/yaml": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", - "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@react-native-community/cli-hermes": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-hermes/-/cli-hermes-11.3.6.tgz", - "integrity": "sha512-O55YAYGZ3XynpUdePPVvNuUPGPY0IJdctLAOHme73OvS80gNwfntHDXfmY70TGHWIfkK2zBhA0B+2v8s5aTyTA==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native-community/cli-platform-android": "11.3.6", - "@react-native-community/cli-tools": "11.3.6", - "chalk": "^4.1.2", - "hermes-profile-transformer": "^0.0.6", - "ip": "^1.1.5" - } - }, - "node_modules/@react-native-community/cli-platform-android": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-11.3.6.tgz", - "integrity": "sha512-ZARrpLv5tn3rmhZc//IuDM1LSAdYnjUmjrp58RynlvjLDI4ZEjBAGCQmgysRgXAsK7ekMrfkZgemUczfn9td2A==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native-community/cli-tools": "11.3.6", - "chalk": "^4.1.2", - "execa": "^5.0.0", - "glob": "^7.1.3", - "logkitty": "^0.7.1" - } - }, - "node_modules/@react-native-community/cli-platform-android/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "optional": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@react-native-community/cli-platform-android/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-platform-android/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-platform-android/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "optional": true, - "peer": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-platform-ios": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-11.3.6.tgz", - "integrity": "sha512-tZ9VbXWiRW+F+fbZzpLMZlj93g3Q96HpuMsS6DRhrTiG+vMQ3o6oPWSEEmMGOvJSYU7+y68Dc9ms2liC7VD6cw==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native-community/cli-tools": "11.3.6", - "chalk": "^4.1.2", - "execa": "^5.0.0", - "fast-xml-parser": "^4.0.12", - "glob": "^7.1.3", - "ora": "^5.4.1" - } - }, - "node_modules/@react-native-community/cli-platform-ios/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "optional": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@react-native-community/cli-platform-ios/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-platform-ios/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-platform-ios/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "optional": true, - "peer": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-plugin-metro": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-plugin-metro/-/cli-plugin-metro-11.3.6.tgz", - "integrity": "sha512-D97racrPX3069ibyabJNKw9aJpVcaZrkYiEzsEnx50uauQtPDoQ1ELb/5c6CtMhAEGKoZ0B5MS23BbsSZcLs2g==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native-community/cli-server-api": "11.3.6", - "@react-native-community/cli-tools": "11.3.6", - "chalk": "^4.1.2", - "execa": "^5.0.0", - "metro": "0.76.7", - "metro-config": "0.76.7", - "metro-core": "0.76.7", - "metro-react-native-babel-transformer": "0.76.7", - "metro-resolver": "0.76.7", - "metro-runtime": "0.76.7", - "readline": "^1.3.0" - } - }, - "node_modules/@react-native-community/cli-plugin-metro/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "optional": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@react-native-community/cli-plugin-metro/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-plugin-metro/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-plugin-metro/node_modules/metro-runtime": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.76.7.tgz", - "integrity": "sha512-MuWHubQHymUWBpZLwuKZQgA/qbb35WnDAKPo83rk7JRLIFPvzXSvFaC18voPuzJBt1V98lKQIonh6MiC9gd8Ug==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/runtime": "^7.0.0", - "react-refresh": "^0.4.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@react-native-community/cli-plugin-metro/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "optional": true, - "peer": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-server-api": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-11.3.6.tgz", - "integrity": "sha512-8GUKodPnURGtJ9JKg8yOHIRtWepPciI3ssXVw5jik7+dZ43yN8P5BqCoDaq8e1H1yRer27iiOfT7XVnwk8Dueg==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native-community/cli-debugger-ui": "11.3.6", - "@react-native-community/cli-tools": "11.3.6", - "compression": "^1.7.1", - "connect": "^3.6.5", - "errorhandler": "^1.5.1", - "nocache": "^3.0.1", - "pretty-format": "^26.6.2", - "serve-static": "^1.13.1", - "ws": "^7.5.1" - } - }, - "node_modules/@react-native-community/cli-server-api/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/@react-native-community/cli-server-api/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/@react-native-community/cli-tools": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-11.3.6.tgz", - "integrity": "sha512-JpmUTcDwAGiTzLsfMlIAYpCMSJ9w2Qlf7PU7mZIRyEu61UzEawyw83DkqfbzDPBuRwRnaeN44JX2CP/yTO3ThQ==", - "optional": true, - "peer": true, - "dependencies": { - "appdirsjs": "^1.2.4", - "chalk": "^4.1.2", - "find-up": "^5.0.0", - "mime": "^2.4.1", - "node-fetch": "^2.6.0", - "open": "^6.2.0", - "ora": "^5.4.1", - "semver": "^7.5.2", - "shell-quote": "^1.7.3" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/is-wsl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", - "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/open": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", - "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", - "optional": true, - "peer": true, - "dependencies": { - "is-wsl": "^1.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-types": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-11.3.6.tgz", - "integrity": "sha512-6DxjrMKx5x68N/tCJYVYRKAtlRHbtUVBZrnAvkxbRWFD9v4vhNgsPM0RQm8i2vRugeksnao5mbnRGpS6c0awCw==", - "optional": true, - "peer": true, - "dependencies": { - "joi": "^17.2.1" - } - }, - "node_modules/@react-native-community/cli/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/@react-native-community/cli/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "optional": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@react-native-community/cli/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "optional": true, - "peer": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@react-native-community/cli/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "optional": true, - "peer": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@react-native-community/cli/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "optional": true, - "peer": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "optional": true, - "peer": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "optional": true, - "peer": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "optional": true, - "peer": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/@react-native/assets-registry": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.72.0.tgz", - "integrity": "sha512-Im93xRJuHHxb1wniGhBMsxLwcfzdYreSZVQGDoMJgkd6+Iky61LInGEHnQCTN0fKNYF1Dvcofb4uMmE1RQHXHQ==", - "optional": true, - "peer": true - }, - "node_modules/@react-native/babel-plugin-codegen": { - "version": "0.74.83", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.74.83.tgz", - "integrity": "sha512-+S0st3t4Ro00bi9gjT1jnK8qTFOU+CwmziA7U9odKyWrCoRJrgmrvogq/Dr1YXlpFxexiGIupGut1VHxr+fxJA==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native/codegen": "0.74.83" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-native/babel-plugin-codegen/node_modules/@react-native/codegen": { - "version": "0.74.83", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.74.83.tgz", - "integrity": "sha512-GgvgHS3Aa2J8/mp1uC/zU8HuTh8ZT5jz7a4mVMWPw7+rGyv70Ba8uOVBq6UH2Q08o617IATYc+0HfyzAfm4n0w==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/parser": "^7.20.0", - "glob": "^7.1.1", - "hermes-parser": "0.19.1", - "invariant": "^2.2.4", - "jscodeshift": "^0.14.0", - "mkdirp": "^0.5.1", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@babel/preset-env": "^7.1.6" - } - }, - "node_modules/@react-native/babel-plugin-codegen/node_modules/hermes-estree": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.19.1.tgz", - "integrity": "sha512-daLGV3Q2MKk8w4evNMKwS8zBE/rcpA800nu1Q5kM08IKijoSnPe9Uo1iIxzPKRkn95IxxsgBMPeYHt3VG4ej2g==", - "optional": true, - "peer": true - }, - "node_modules/@react-native/babel-plugin-codegen/node_modules/hermes-parser": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.19.1.tgz", - "integrity": "sha512-Vp+bXzxYJWrpEuJ/vXxUsLnt0+y4q9zyi4zUlkLqD8FKv4LjIfOvP69R/9Lty3dCyKh0E2BU7Eypqr63/rKT/A==", - "optional": true, - "peer": true, - "dependencies": { - "hermes-estree": "0.19.1" - } - }, - "node_modules/@react-native/babel-preset": { - "version": "0.74.83", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.74.83.tgz", - "integrity": "sha512-KJuu3XyVh3qgyUer+rEqh9a/JoUxsDOzkJNfRpDyXiAyjDRoVch60X/Xa/NcEQ93iCVHAWs0yQ+XGNGIBCYE6g==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.20.0", - "@babel/plugin-proposal-async-generator-functions": "^7.0.0", - "@babel/plugin-proposal-class-properties": "^7.18.0", - "@babel/plugin-proposal-export-default-from": "^7.0.0", - "@babel/plugin-proposal-logical-assignment-operators": "^7.18.0", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.0", - "@babel/plugin-proposal-numeric-separator": "^7.0.0", - "@babel/plugin-proposal-object-rest-spread": "^7.20.0", - "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", - "@babel/plugin-proposal-optional-chaining": "^7.20.0", - "@babel/plugin-syntax-dynamic-import": "^7.8.0", - "@babel/plugin-syntax-export-default-from": "^7.0.0", - "@babel/plugin-syntax-flow": "^7.18.0", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.0.0", - "@babel/plugin-syntax-optional-chaining": "^7.0.0", - "@babel/plugin-transform-arrow-functions": "^7.0.0", - "@babel/plugin-transform-async-to-generator": "^7.20.0", - "@babel/plugin-transform-block-scoping": "^7.0.0", - "@babel/plugin-transform-classes": "^7.0.0", - "@babel/plugin-transform-computed-properties": "^7.0.0", - "@babel/plugin-transform-destructuring": "^7.20.0", - "@babel/plugin-transform-flow-strip-types": "^7.20.0", - "@babel/plugin-transform-function-name": "^7.0.0", - "@babel/plugin-transform-literals": "^7.0.0", - "@babel/plugin-transform-modules-commonjs": "^7.0.0", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.0.0", - "@babel/plugin-transform-parameters": "^7.0.0", - "@babel/plugin-transform-private-methods": "^7.22.5", - "@babel/plugin-transform-private-property-in-object": "^7.22.11", - "@babel/plugin-transform-react-display-name": "^7.0.0", - "@babel/plugin-transform-react-jsx": "^7.0.0", - "@babel/plugin-transform-react-jsx-self": "^7.0.0", - "@babel/plugin-transform-react-jsx-source": "^7.0.0", - "@babel/plugin-transform-runtime": "^7.0.0", - "@babel/plugin-transform-shorthand-properties": "^7.0.0", - "@babel/plugin-transform-spread": "^7.0.0", - "@babel/plugin-transform-sticky-regex": "^7.0.0", - "@babel/plugin-transform-typescript": "^7.5.0", - "@babel/plugin-transform-unicode-regex": "^7.0.0", - "@babel/template": "^7.0.0", - "@react-native/babel-plugin-codegen": "0.74.83", - "babel-plugin-transform-flow-enums": "^0.0.2", - "react-refresh": "^0.14.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "node_modules/@react-native/babel-preset/node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@react-native/codegen": { - "version": "0.72.7", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.72.7.tgz", - "integrity": "sha512-O7xNcGeXGbY+VoqBGNlZ3O05gxfATlwE1Q1qQf5E38dK+tXn5BY4u0jaQ9DPjfE8pBba8g/BYI1N44lynidMtg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/parser": "^7.20.0", - "flow-parser": "^0.206.0", - "jscodeshift": "^0.14.0", - "nullthrows": "^1.1.1" - }, - "peerDependencies": { - "@babel/preset-env": "^7.1.6" - } - }, - "node_modules/@react-native/debugger-frontend": { - "version": "0.74.83", - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.74.83.tgz", - "integrity": "sha512-RGQlVUegBRxAUF9c1ss1ssaHZh6CO+7awgtI9sDeU0PzDZY/40ImoPD5m0o0SI6nXoVzbPtcMGzU+VO590pRfA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-native/dev-middleware": { - "version": "0.74.83", - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.74.83.tgz", - "integrity": "sha512-UH8iriqnf7N4Hpi20D7M2FdvSANwTVStwFCSD7VMU9agJX88Yk0D1T6Meh2RMhUu4kY2bv8sTkNRm7LmxvZqgA==", - "optional": true, - "peer": true, - "dependencies": { - "@isaacs/ttlcache": "^1.4.1", - "@react-native/debugger-frontend": "0.74.83", - "@rnx-kit/chromium-edge-launcher": "^1.0.0", - "chrome-launcher": "^0.15.2", - "connect": "^3.6.5", - "debug": "^2.2.0", - "node-fetch": "^2.2.0", - "nullthrows": "^1.1.1", - "open": "^7.0.3", - "selfsigned": "^2.4.1", - "serve-static": "^1.13.1", - "temp-dir": "^2.0.0", - "ws": "^6.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-native/dev-middleware/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/@react-native/dev-middleware/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true - }, - "node_modules/@react-native/dev-middleware/node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "optional": true, - "peer": true, - "dependencies": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native/dev-middleware/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", - "optional": true, - "peer": true, - "dependencies": { - "async-limiter": "~1.0.0" - } - }, - "node_modules/@react-native/gradle-plugin": { - "version": "0.72.11", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.72.11.tgz", - "integrity": "sha512-P9iRnxiR2w7EHcZ0mJ+fmbPzMby77ZzV6y9sJI3lVLJzF7TLSdbwcQyD3lwMsiL+q5lKUHoZJS4sYmih+P2HXw==", - "optional": true, - "peer": true - }, - "node_modules/@react-native/js-polyfills": { - "version": "0.72.1", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.72.1.tgz", - "integrity": "sha512-cRPZh2rBswFnGt5X5EUEPs0r+pAsXxYsifv/fgy9ZLQokuT52bPH+9xjDR+7TafRua5CttGW83wP4TntRcWNDA==", - "optional": true, - "peer": true - }, - "node_modules/@react-native/normalize-colors": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.72.0.tgz", - "integrity": "sha512-285lfdqSXaqKuBbbtP9qL2tDrfxdOFtIMvkKadtleRQkdOxx+uzGvFr82KHmc/sSiMtfXGp7JnFYWVh4sFl7Yw==", - "optional": true, - "peer": true - }, - "node_modules/@react-native/virtualized-lists": { - "version": "0.72.8", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", - "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", - "optional": true, - "peer": true, - "dependencies": { - "invariant": "^2.2.4", - "nullthrows": "^1.1.1" - }, - "peerDependencies": { - "react-native": "*" - } - }, - "node_modules/@rnx-kit/chromium-edge-launcher": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rnx-kit/chromium-edge-launcher/-/chromium-edge-launcher-1.0.0.tgz", - "integrity": "sha512-lzD84av1ZQhYUS+jsGqJiCMaJO2dn9u+RTT9n9q6D3SaKVwWqv+7AoRKqBu19bkwyE+iFRl1ymr40QS90jVFYg==", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "^18.0.0", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^1.0.0", - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=14.15" - } - }, - "node_modules/@rnx-kit/chromium-edge-launcher/node_modules/@types/node": { - "version": "18.19.33", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.33.tgz", - "integrity": "sha512-NR9+KrpSajr2qBVp/Yt5TU/rp+b5Mayi3+OlMlcg2cVCfRmcG5PWZ7S4+MG9PZ5gWBoc9Pd0BKSRViuBCRPu0A==", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@rnx-kit/chromium-edge-launcher/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "optional": true, - "peer": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@sapphire/async-queue": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", - "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@sapphire/shapeshift": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", - "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=v16" - } - }, - "node_modules/@sapphire/snowflake": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", - "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@scderox/ikea-name-generator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@scderox/ikea-name-generator/-/ikea-name-generator-1.0.0.tgz", - "integrity": "sha512-tBeB6sfUR6ZzrwWDbjQejuij09+KAQAAI9eba8DKe+ZARDTgbaXhjMU25NyU0AL+qPgBy4Nm8ZPyncy8Ee8Abw==", - "dependencies": { - "compromise": "^13.11.2", - "nlp_compromise": "^4.12.0", - "nlp-syllables": "^0.0.5" - } - }, - "node_modules/@segment/loosely-validate-event": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", - "integrity": "sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==", - "optional": true, - "peer": true, - "dependencies": { - "component-type": "^1.2.1", - "join-component": "^1.1.0" - } - }, - "node_modules/@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "optional": true, - "peer": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "optional": true, - "peer": true - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "optional": true, - "peer": true - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "optional": true, - "peer": true - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", - "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", - "optional": true, - "peer": true, - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "optional": true, - "peer": true, - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "optional": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@twurple/api": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@twurple/api/-/api-5.3.4.tgz", - "integrity": "sha512-i1THeJ4CgsTSmGtjdtk81gZoxfJxJrke8mJyC62jmgvRK18kEILcdEjjCsboLq4Tvp2js8fs1X2zwd4t8FgsLQ==", - "dependencies": { - "@d-fischer/cache-decorators": "^3.0.0", - "@d-fischer/detect-node": "^3.0.1", - "@d-fischer/logger": "^4.0.0", - "@d-fischer/rate-limiter": "^0.6.1", - "@d-fischer/shared-utils": "^3.4.0", - "@twurple/api-call": "5.3.4", - "@twurple/common": "5.3.4", - "tslib": "^2.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "@twurple/auth": "5.3.4" - } - }, - "node_modules/@twurple/api-call": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-5.3.4.tgz", - "integrity": "sha512-LSMdS1+K59PwPCcNtphpILnoUBo7xxkJYq0heppQW0F8lu8ANz34NvjjmlV5vZg5NF+VpfAztiYZIi/gRuVgvw==", - "dependencies": { - "@d-fischer/cross-fetch": "^4.0.2", - "@d-fischer/qs": "^7.0.2", - "@d-fischer/shared-utils": "^3.4.0", - "@twurple/common": "5.3.4", - "@types/node-fetch": "^2.5.7", - "tslib": "^2.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - } - }, - "node_modules/@twurple/auth": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@twurple/auth/-/auth-5.3.4.tgz", - "integrity": "sha512-qoChaHplRLJQsQaz6bFstR+/6VpnyUUj69jbqcXsJEUsetXWjhLT3cp0JDVKs8r4qMjZptr4XYq8kfHFZJhbHg==", - "dependencies": { - "@d-fischer/logger": "^4.0.0", - "@d-fischer/shared-utils": "^3.4.0", - "@twurple/api-call": "5.3.4", - "@twurple/common": "5.3.4", - "tslib": "^2.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - } - }, - "node_modules/@twurple/common": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@twurple/common/-/common-5.3.4.tgz", - "integrity": "sha512-vMpuhoNAlETwOSJDUYrxYBahAaLJWOn0lEs+eeCea32Z6cefvs/qXsQ3p2Kl9aY3bic+wGhHW0uB4Os7ZS3hHA==", - "dependencies": { - "@d-fischer/shared-utils": "^3.4.0", - "klona": "^2.0.4", - "tslib": "^2.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - } - }, - "node_modules/@types/debug": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", - "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", - "optional": true, - "peer": true - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", - "optional": true, - "peer": true, - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } + "os": [ + "linux" + ] }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/ms": { - "version": "0.7.31", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", - "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==" - }, - "node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" - }, - "node_modules/@types/node-fetch": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.5.tgz", - "integrity": "sha512-OZsUlr2nxvkqUFLSaY2ZbA+P1q22q+KrlxWOn/38RX+u5kTkYL2mTujEpzUhGkS+K/QCYp9oagfXG39XOzyySg==", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.0" - } + "os": [ + "linux" + ] }, - "node_modules/@types/node-forge": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", - "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*" - } + "os": [ + "linux" + ] }, - "node_modules/@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, - "peer": true - }, - "node_modules/@types/validator": { - "version": "13.11.1", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.1.tgz", - "integrity": "sha512-d/MUkJYdOeKycmm75Arql4M5+UuXmf4cHdHKsyw1GcvnNgL6s77UkgSgJ8TE/rI5PYsnwYq5jkcWBLuN/MpQ1A==" + "os": [ + "linux" + ] }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dependencies": { - "@types/node": "*" - } + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/yargs": { - "version": "15.0.15", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.15.tgz", - "integrity": "sha512-IziEYMU9XoVj8hWg7k+UJrXALkGFjWJhn5QFEv9q4p+v40oZhSuC135M38st8XPjICL7Ey4TV64ferBGUoJhBg==", + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" - } + "os": [ + "linux" + ] }, - "node_modules/@types/yargs-parser": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true + "os": [ + "openharmony" + ] }, - "node_modules/@unimodules/core": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@unimodules/core/-/core-7.1.2.tgz", - "integrity": "sha512-lY+e2TAFuebD3vshHMIRqru3X4+k7Xkba4Wa7QsDBd+ex4c4N2dHAO61E2SrGD9+TRBD8w/o7mzK6ljbqRnbyg==", - "deprecated": "replaced by the 'expo' package, learn more: https://blog.expo.dev/whats-new-in-expo-modules-infrastructure-7a7cdda81ebc", + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "compare-versions": "^3.4.0" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@unimodules/react-native-adapter": { - "version": "6.3.9", - "resolved": "https://registry.npmjs.org/@unimodules/react-native-adapter/-/react-native-adapter-6.3.9.tgz", - "integrity": "sha512-i9/9Si4AQ8awls+YGAKkByFbeAsOPgUNeLoYeh2SQ3ddjxJ5ZJDtq/I74clDnpDcn8zS9pYlcDJ9fgVJa39Glw==", - "deprecated": "replaced by the 'expo' package, learn more: https://blog.expo.dev/whats-new-in-expo-modules-infrastructure-7a7cdda81ebc", + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, - "dependencies": { - "expo-modules-autolinking": "^0.0.3", - "invariant": "^2.2.4" - } + "os": [ + "win32" + ] }, - "node_modules/@urql/core": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@urql/core/-/core-2.3.6.tgz", - "integrity": "sha512-PUxhtBh7/8167HJK6WqBv6Z0piuiaZHQGYbhwpNL9aIQmLROPEdaUYkY4wh45wPQXcTpnd11l0q3Pw+TI11pdw==", + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.0", - "wonka": "^4.0.14" - }, - "peerDependencies": { - "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } + "os": [ + "win32" + ] }, - "node_modules/@urql/exchange-retry": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-0.3.0.tgz", - "integrity": "sha512-hHqer2mcdVC0eYnVNbWyi28AlGOPb2vjH3lP3/Bc8Lc8BjhMsDwFMm7WhoP5C1+cfbr/QJ6Er3H/L08wznXxfg==", + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "@urql/core": ">=2.3.1", - "wonka": "^4.0.14" - }, - "peerDependencies": { - "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" - } + "os": [ + "win32" + ] }, "node_modules/@vladfrangu/async_event_emitter": { "version": "2.4.7", @@ -5998,54 +3512,22 @@ "npm": ">=7.0.0" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.7.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", - "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "optional": true - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "optional": true, - "peer": true, - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", "optional": true, - "peer": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, "engines": { - "node": ">= 0.6" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "devOptional": true, + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -6058,6 +3540,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -6067,48 +3550,12 @@ "resolved": "https://registry.npmjs.org/age-calculator/-/age-calculator-1.0.0.tgz", "integrity": "sha512-3+vuEZXhfUpwl70cHJ/0g1r1nxVMzKjuOSuwXZdPRJ/z9vZMUj4/DfzkQwVABSaG0YEdr1zz6hlTR1g3lYvolg==" }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "optional": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", - "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "optional": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6120,17 +3567,11 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/anser": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", - "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", - "optional": true, - "peer": true - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, "dependencies": { "type-fest": "^0.21.3" }, @@ -6145,6 +3586,7 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, "engines": { "node": ">=10" }, @@ -6152,45 +3594,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-fragments": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/ansi-fragments/-/ansi-fragments-0.2.1.tgz", - "integrity": "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==", - "optional": true, - "peer": true, - "dependencies": { - "colorette": "^1.0.7", - "slice-ansi": "^2.0.0", - "strip-ansi": "^5.0.0" - } - }, - "node_modules/ansi-fragments/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-fragments/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -6199,6 +3607,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -6209,19 +3618,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "optional": true, - "peer": true - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "optional": true, - "peer": true, + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -6230,32 +3631,6 @@ "node": ">= 8" } }, - "node_modules/appdirsjs": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.7.tgz", - "integrity": "sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==", - "optional": true, - "peer": true - }, - "node_modules/aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", - "optional": true - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "optional": true, - "peer": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "devOptional": true - }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", @@ -6271,34 +3646,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/array.prototype.map": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.6.tgz", - "integrity": "sha512-nK1psgF2cXqP3wSyCSq0Hc7zwNq3sfljQqaG27r/7a7ooNUnn5nGq6yYWyks9jMO5EoFQ0ax80hSg6oXSRNXaw==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-array-method-boxes-properly": "^1.0.0", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", @@ -6320,82 +3667,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "optional": true, - "peer": true - }, - "node_modules/asmcrypto.js": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-0.22.0.tgz", - "integrity": "sha512-usgMoyXjMbx/ZPdzTSXExhMPur2FTdz/Vo5PVx2gIaBcdAAJNOFlsdgqveM8Cff7W0v+xrf9BwjOV26JSAF9qA==" - }, - "node_modules/asn1js": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", - "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", - "dependencies": { - "pvtsutils": "^1.3.2", - "pvutils": "^1.1.3", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/ast-types": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz", - "integrity": "sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/astral-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", - "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/async": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", - "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", - "optional": true, - "peer": true - }, - "node_modules/async-limiter": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "optional": true, - "peer": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "optional": true, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6407,201 +3678,113 @@ "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "dependencies": { - "follow-redirects": "^1.14.0" - } - }, - "node_modules/b64-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/b64-lite/-/b64-lite-1.4.0.tgz", - "integrity": "sha512-aHe97M7DXt+dkpa8fHlCcm1CnskAHrJqEfMI0KN7dwqlzml/aUe1AGt6lk51HzrSfVD67xOso84sOpr+0wIe2w==", - "dependencies": { - "base-64": "^0.1.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/b64u-lite": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/b64u-lite/-/b64u-lite-1.1.0.tgz", - "integrity": "sha512-929qWGDVCRph7gQVTC6koHqQIpF4vtVaSbwLltFQo44B1bYUquALswZdBKFfrJCPEnsCOvWkJsPdQYZ/Ukhw8A==", + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", "dependencies": { - "b64-lite": "^1.4.0" - } - }, - "node_modules/babel-core": { - "version": "7.0.0-bridge.0", - "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", - "integrity": "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==", - "optional": true, - "peer": true, + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.5.tgz", - "integrity": "sha512-19hwUH5FKl49JEsvyTcoHakh6BE0wgXLLptIyKZ3PijHc/Ci521wygORCUCCred+E/twuqRyAkE02BAWPmsHOg==", - "optional": true, - "peer": true, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.4.2", - "semver": "^6.3.1" + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" + "engines": { + "node": ">=12" } }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.3.tgz", - "integrity": "sha512-z41XaniZL26WLrvjy7soabMXrfPWARN25PZoriDEiLMxAp50AUW3t35BGQUMg5xK3UrpVTtagIDklxYa+MhiNA==", - "optional": true, - "peer": true, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.2", - "core-js-compat": "^3.31.0" + "@types/babel__core": "^7.20.5" }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.2.tgz", - "integrity": "sha512-tAlOptU0Xj34V1Y2PNTL4Y0FOJMDB6bZmoW39FeCQIhigGLkqu3Fj6uiXpxIf6Ij274ENdYx64y6Au+ZKlb1IA==", - "optional": true, - "peer": true, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.2" + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "@babel/core": "^7.0.0 || ^8.0.0-0" } }, - "node_modules/babel-plugin-react-native-web": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.19.11.tgz", - "integrity": "sha512-0sHf8GgDhsRZxGwlwHHdfL3U8wImFaLw4haEa60U9M3EiO3bg6u3BJ+1vXhwgrevqSq76rMb5j1HJs+dNvMj5g==", - "optional": true, - "peer": true - }, - "node_modules/babel-plugin-syntax-trailing-function-commas": { - "version": "7.0.0-beta.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-7.0.0-beta.0.tgz", - "integrity": "sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ==", - "optional": true, - "peer": true - }, - "node_modules/babel-plugin-transform-flow-enums": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", - "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", - "optional": true, - "peer": true, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/plugin-syntax-flow": "^7.12.1" - } - }, - "node_modules/babel-preset-expo": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-11.0.5.tgz", - "integrity": "sha512-IjqR4B7wnBU55pofLeLGjwUGrWJE1buamgzE9CYpYCNicZmJcNjXUcinQiurXCMuClF2hOff3QfZsLxnGj1UaA==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-transform-export-namespace-from": "^7.22.11", - "@babel/plugin-transform-object-rest-spread": "^7.12.13", - "@babel/plugin-transform-parameters": "^7.22.15", - "@babel/preset-react": "^7.22.15", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-preset": "~0.74.83", - "babel-plugin-react-native-web": "~0.19.10", - "react-refresh": "^0.14.2" - } - }, - "node_modules/babel-preset-expo/node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "optional": true, - "peer": true, + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/babel-preset-fbjs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/babel-preset-fbjs/-/babel-preset-fbjs-3.4.0.tgz", - "integrity": "sha512-9ywCsCvo1ojrw0b+XYk7aFvTH6D9064t0RIL1rtMf3nsa02Xw41MS7sZw216Im35xj/UY0PDBQsa1brUDDF1Ow==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/plugin-proposal-class-properties": "^7.0.0", - "@babel/plugin-proposal-object-rest-spread": "^7.0.0", - "@babel/plugin-syntax-class-properties": "^7.0.0", - "@babel/plugin-syntax-flow": "^7.0.0", - "@babel/plugin-syntax-jsx": "^7.0.0", - "@babel/plugin-syntax-object-rest-spread": "^7.0.0", - "@babel/plugin-transform-arrow-functions": "^7.0.0", - "@babel/plugin-transform-block-scoped-functions": "^7.0.0", - "@babel/plugin-transform-block-scoping": "^7.0.0", - "@babel/plugin-transform-classes": "^7.0.0", - "@babel/plugin-transform-computed-properties": "^7.0.0", - "@babel/plugin-transform-destructuring": "^7.0.0", - "@babel/plugin-transform-flow-strip-types": "^7.0.0", - "@babel/plugin-transform-for-of": "^7.0.0", - "@babel/plugin-transform-function-name": "^7.0.0", - "@babel/plugin-transform-literals": "^7.0.0", - "@babel/plugin-transform-member-expression-literals": "^7.0.0", - "@babel/plugin-transform-modules-commonjs": "^7.0.0", - "@babel/plugin-transform-object-super": "^7.0.0", - "@babel/plugin-transform-parameters": "^7.0.0", - "@babel/plugin-transform-property-literals": "^7.0.0", - "@babel/plugin-transform-react-display-name": "^7.0.0", - "@babel/plugin-transform-react-jsx": "^7.0.0", - "@babel/plugin-transform-shorthand-properties": "^7.0.0", - "@babel/plugin-transform-spread": "^7.0.0", - "@babel/plugin-transform-template-literals": "^7.0.0", - "babel-plugin-syntax-trailing-function-commas": "^7.0.0-beta.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "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==", - "devOptional": true - }, - "node_modules/base-64": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", - "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" - }, - "node_modules/base-x": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", - "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==" + "dev": true }, "node_modules/base64-js": { "version": "1.5.1", @@ -6622,27 +3805,17 @@ } ] }, - "node_modules/better-opn": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", - "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", - "optional": true, - "peer": true, - "dependencies": { - "open": "^8.0.4" + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.6" + "node": ">=6.0.0" } }, "node_modules/bindings": { @@ -6663,55 +3836,21 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bplist-creator": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", - "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", - "optional": true, - "peer": true, - "dependencies": { - "stream-buffers": "2.2.x" - } - }, - "node_modules/bplist-parser": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", - "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", - "optional": true, - "peer": true, - "dependencies": { - "big-integer": "1.6.x" - }, - "engines": { - "node": ">= 5.10.0" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "devOptional": true, + "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "optional": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { - "version": "4.21.10", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz", - "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6726,13 +3865,13 @@ "url": "https://github.com/sponsors/ai" } ], - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001517", - "electron-to-chromium": "^1.4.477", - "node-releases": "^2.0.13", - "update-browserslist-db": "^1.0.11" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -6741,20 +3880,11 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bs58": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", - "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", - "dependencies": { - "base-x": "^4.0.0" - } - }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "optional": true, - "peer": true, + "dev": true, "dependencies": { "node-int64": "^0.4.0" } @@ -6782,43 +3912,19 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "optional": true, - "peer": true, - "dependencies": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "node_modules/buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "optional": true, - "peer": true - }, - "node_modules/buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", - "optional": true, - "peer": true - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "optional": true, - "peer": true + "dev": true }, "node_modules/bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", "hasInstallScript": true, + "license": "MIT", + "optional": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -6826,87 +3932,6 @@ "node": ">=6.14.2" } }, - "node_modules/builtins": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", - "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", - "optional": true, - "peer": true - }, - "node_modules/byte-base64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/byte-base64/-/byte-base64-1.1.0.tgz", - "integrity": "sha512-56cXelkJrVMdCY9V/3RfDxTh4VfMFCQ5km7B7GkIGfo4bcPL9aACyJLB0Ms3Ezu5rsHmLB2suis96z4fLM03DA==" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "optional": true, - "dependencies": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "optional": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -6937,208 +3962,96 @@ "node": ">= 0.4" } }, - "node_modules/caller-callsite": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", - "integrity": "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ==", - "optional": true, - "peer": true, - "dependencies": { - "callsites": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/caller-callsite/node_modules/callsites": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", - "integrity": "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/caller-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", - "integrity": "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A==", - "optional": true, - "peer": true, - "dependencies": { - "caller-callsite": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/camelcase": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001534", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001534.tgz", - "integrity": "sha512-vlPVrhsCS7XaSh2VvWluIQEzVhefrUQcEsQWSS5A5V+dM07uv1qHeQzAOTGIMy9i3e9bH15+muvI/UHojVgS/Q==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "optional": true, - "peer": true - }, - "node_modules/centra": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/centra/-/centra-2.6.0.tgz", - "integrity": "sha512-dgh+YleemrT8u85QL11Z6tYhegAs3MMxsaWAq/oXeAmYJ7VxL3SI9TZtnfaEvNDMAPolj25FXIb3S+HCI4wQaQ==" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==" - }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "optional": true, - "peer": true, - "engines": { - "node": "*" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "engines": { - "node": ">=10" - } - }, - "node_modules/chrome-launcher": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", - "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^1.0.0" - }, - "bin": { - "print-chrome-path": "bin/print-chrome-path.js" - }, + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "engines": { - "node": ">=12.13.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ci-info": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", - "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, { "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" + "url": "https://github.com/sponsors/ai" } ], - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } + "license": "CC-BY-4.0" }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "optional": true, - "engines": { - "node": ">=6" + "node_modules/centra": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", + "integrity": "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6" } }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { - "restore-cursor": "^3.1.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.1.tgz", - "integrity": "sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ==", - "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=10" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "optional": true, - "peer": true, + "dev": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -7152,8 +4065,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "optional": true, - "peer": true, + "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -7166,35 +4078,29 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "optional": true, - "peer": true, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.8" + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" } }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "optional": true, - "peer": true, - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -7205,152 +4111,8 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "optional": true, - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "optional": true, - "peer": true - }, - "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==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/command-exists": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", - "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", - "optional": true, - "peer": true - }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "engines": { - "node": ">= 10" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "optional": true, - "peer": true - }, - "node_modules/compare-versions": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", - "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", - "optional": true - }, - "node_modules/component-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/component-type/-/component-type-1.2.2.tgz", - "integrity": "sha512-99VUHREHiN5cLeHm3YLq312p6v+HUEcwtLCAtelvUDI6+SH5g5Cr85oNR2S1o6ywzL0ykMbuwLzM2ANocjEOIA==", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "optional": true, - "peer": true, - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "optional": true, - "peer": true, - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true - }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "optional": true, - "peer": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/compromise": { "version": "13.11.4", @@ -7367,138 +4129,14 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true - }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "optional": true, - "peer": true, - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "optional": true + "dev": true }, "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "optional": true, - "peer": true - }, - "node_modules/core-js-compat": { - "version": "3.32.2", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.32.2.tgz", - "integrity": "sha512-+GjlguTDINOijtVRUxrQOv3kfu9rl+qPNdX2LTbJ/ZyVTuxK+ksVSAGX1nHstu4hrv1En/uPTtWgq2gI5wt4AQ==", - "optional": true, - "peer": true, - "dependencies": { - "browserslist": "^4.21.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "optional": true, - "peer": true - }, - "node_modules/cosmiconfig": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", - "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", - "optional": true, - "peer": true, - "dependencies": { - "import-fresh": "^2.0.0", - "is-directory": "^0.3.1", - "js-yaml": "^3.13.1", - "parse-json": "^4.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cosmiconfig/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "optional": true, - "peer": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/cosmiconfig/node_modules/import-fresh": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", - "integrity": "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==", - "optional": true, - "peer": true, - "dependencies": { - "caller-path": "^2.0.0", - "resolve-from": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "optional": true, - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/cosmiconfig/node_modules/resolve-from": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", - "integrity": "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" }, "node_modules/cron-parser": { "version": "4.9.0", @@ -7511,21 +4149,11 @@ "node": ">=12.0.0" } }, - "node_modules/cross-fetch": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", - "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", - "optional": true, - "peer": true, - "dependencies": { - "node-fetch": "^2.6.12" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -7535,33 +4163,6 @@ "node": ">= 8" } }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "optional": true, - "peer": true, - "engines": { - "node": "*" - } - }, - "node_modules/crypto-random-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/dag-map": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz", - "integrity": "sha512-+LSAiGFwQ9dRnRdOeaj7g47ZFJcOUPukAP8J3A3fuZ1g9Y44BG+P1sgApjLXTQPOzC4+7S9Wr8kXsfpINM4jpw==", - "optional": true, - "peer": true - }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -7618,13 +4219,6 @@ "node": ">=4.0" } }, - "node_modules/dayjs": { - "version": "1.11.9", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", - "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==", - "optional": true, - "peer": true - }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -7641,16 +4235,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -7665,6 +4249,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -7683,45 +4282,11 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/default-gateway": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz", - "integrity": "sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==", - "optional": true, - "peer": true, - "dependencies": { - "execa": "^1.0.0", - "ip-regex": "^2.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defaults/node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "engines": { - "node": ">=0.8" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -7738,16 +4303,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -7764,83 +4319,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/del": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", - "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", - "optional": true, - "peer": true, - "dependencies": { - "globby": "^11.0.1", - "graceful-fs": "^4.2.4", - "is-glob": "^4.0.1", - "is-path-cwd": "^2.2.0", - "is-path-inside": "^3.0.2", - "p-map": "^4.0.0", - "rimraf": "^3.0.2", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "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==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "optional": true - }, - "node_modules/denodeify": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/denodeify/-/denodeify-1.2.1.tgz", - "integrity": "sha512-KNTihKNmQENUZeKu5fzfpzRqR5S2VMp4gl9RFHiWzj9DfvYQPMJ6XHKNaQxaGCXwPk6y9yme3aUoaiAe+KX+vg==", - "optional": true, - "peer": true - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/deprecated-react-native-prop-types": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-4.1.0.tgz", - "integrity": "sha512-WfepZHmRbbdTvhcolb8aOKEvQdcmTMn5tKLbqbXmkBvjFjRVWAYqsXk/DBsV8TZxws8SdGHLuHaJrHSQUPRdfw==", - "optional": true, - "peer": true, - "dependencies": { - "@react-native/normalize-colors": "*", - "invariant": "*", - "prop-types": "*" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -7849,59 +4327,44 @@ "node": ">=8" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "optional": true, - "peer": true, - "dependencies": { - "path-type": "^4.0.0" - }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/discord-api-types": { - "version": "0.38.37", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.37.tgz", - "integrity": "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==", + "version": "0.38.48", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.48.tgz", + "integrity": "sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ==", + "license": "MIT", "workspaces": [ "scripts/actions/documentation" ] }, - "node_modules/discord-logs": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/discord-logs/-/discord-logs-2.2.1.tgz", - "integrity": "sha512-VTNe/uRcfdLDLBLf1Taaj3OYU1GLWTAVEcCPC/xZqZd1X4D3DXW1qYJWxoyx3yqiJZ4rwQ3A0bPIFryIdniKrQ==", - "dependencies": { - "@types/node": "^18.7.11", - "@types/ws": "^8.5.3" - } - }, - "node_modules/discord-logs/node_modules/@types/node": { - "version": "18.17.16", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.16.tgz", - "integrity": "sha512-e0zgs7qe1XH/X3KEPnldfkD07LH9O1B9T31U8qoO7lqGSjj3/IrBuvqMeJ1aYejXRK3KOphIUDw6pLIplEW17A==" - }, "node_modules/discord.js": { - "version": "14.25.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", - "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.0", + "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -7910,58 +4373,6 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" - } - }, - "node_modules/dotenv-expand": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.6.tgz", - "integrity": "sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==", - "optional": true, - "peer": true, - "dependencies": { - "dotenv": "^16.4.4" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand/node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dottie": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", @@ -7980,12 +4391,12 @@ "node": ">= 0.4" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "optional": true, - "peer": true + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" }, "node_modules/efrt-unpack": { "version": "2.2.0", @@ -7993,48 +4404,30 @@ "integrity": "sha512-9xUSSj7qcUxz+0r4X3+bwUNttEfGfK5AH+LVa1aTpqdAfrN5VhROYCfcF+up4hp5OL7IUKcZJJrzAGipQRDoiQ==" }, "node_modules/electron-to-chromium": { - "version": "1.4.523", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.523.tgz", - "integrity": "sha512-9AreocSUWnzNtvLcbpng6N+GkXnCcBR80IQkxRC9Dfdyg4gaWNUPBujAHUpKkiUkoSoR9UlhA4zD/IgBklmhzg==", - "optional": true, - "peer": true - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "version": "1.5.365", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.365.tgz", + "integrity": "sha512-xfip4u1QF1s+URFqpA6N+OeFpDGpN7VJz1f3MO3bVL0QYBjpGiZ5/Of7kugvM+o8TTqmanUlviHN3c8M9vYWCw==", + "dev": true, + "license": "ISC" }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "optional": true, - "peer": true, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "license": "MIT", "engines": { - "node": ">= 0.8" - } - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "node": ">=12" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -8043,88 +4436,36 @@ "once": "^1.4.0" } }, - "node_modules/env-editor": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", - "integrity": "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", "optional": true, "engines": { "node": ">=6" } }, - "node_modules/envinfo": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.10.0.tgz", - "integrity": "sha512-ZtUjZO6l5mwTHvc1L9+1q5p/R3wTopcfqMW8r5t8SJSKqeVI/LtajORwRFEKpEFuekjD0VBjwu1HMxL4UalIRw==", - "optional": true, - "peer": true, - "bin": { - "envinfo": "dist/cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/erlpack": { "version": "0.1.3", "resolved": "git+ssh://git@github.com/discord/erlpack.git#cbe76be04c2210fc9cb6ff95910f0937c1011d04", "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { "bindings": "^1.5.0", "nan": "^2.15.0" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "optional": true - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "optional": true, - "peer": true, + "dev": true, "dependencies": { "is-arrayish": "^0.2.1" } }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "optional": true, - "peer": true, - "dependencies": { - "stackframe": "^1.3.4" - } - }, - "node_modules/errorhandler": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.1.tgz", - "integrity": "sha512-rcOwbfvP1WTViVoUjcfZicVzjhjTuhSMntHh6mW3IrEiyE6mJyXvsToJUJGlGlw/2xU9P5whlWNGlIDVeCiT4A==", - "optional": true, - "peer": true, - "dependencies": { - "accepts": "~1.3.7", - "escape-html": "~1.0.3" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/es-abstract": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", @@ -8184,11 +4525,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-array-method-boxes-properly": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -8266,129 +4602,210 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "optional": true, - "peer": true, + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, "engines": { - "node": ">=6" + "node": "18 || 20 || >=22" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "optional": true, - "peer": true - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "devOptional": true, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.49.0.tgz", - "integrity": "sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.2", - "@eslint/js": "8.49.0", - "@humanwhocodes/config-array": "^0.11.11", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -8398,8 +4815,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "optional": true, - "peer": true, + "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -8425,6 +4841,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -8445,883 +4862,890 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "optional": true, - "peer": true, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8.0" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "optional": true, - "peer": true, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "engines": { "node": ">=6" } }, - "node_modules/exec-async": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", - "integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==", - "optional": true, - "peer": true + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } }, - "node_modules/execa": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", - "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", - "optional": true, - "peer": true, + "node_modules/expect/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "cross-spawn": "^6.0.0", - "get-stream": "^4.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=6" + "node": ">=6.9.0" } }, - "node_modules/execa/node_modules/cross-spawn": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", - "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", - "optional": true, - "peer": true, + "node_modules/expect/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=4.8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/execa/node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", - "optional": true, - "peer": true, + "node_modules/expect/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, "engines": { - "node": ">=4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/execa/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver" + "node_modules/expect/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" } }, - "node_modules/execa/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "optional": true, - "peer": true, + "node_modules/expect/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/expect/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/expect/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", "dependencies": { - "shebang-regex": "^1.0.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/execa/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "optional": true, - "peer": true, + "node_modules/expect/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/execa/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "optional": true, - "peer": true, + "node_modules/expect/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, - "bin": { - "which": "bin/which" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "node_modules/expect/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/expo": { - "version": "51.0.2", - "resolved": "https://registry.npmjs.org/expo/-/expo-51.0.2.tgz", - "integrity": "sha512-aRKrheMMQBcNDg2SBjW5kcSN5G58bdIpsxeSQ65Bx18DFLXjPv5UaU9kzIWRAcxaPtgictn9ut9IJQVZKChNxQ==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/runtime": "^7.20.0", - "@expo/cli": "0.18.10", - "@expo/config": "9.0.1", - "@expo/config-plugins": "8.0.4", - "@expo/metro-config": "0.18.3", - "@expo/vector-icons": "^14.0.0", - "babel-preset-expo": "~11.0.5", - "expo-asset": "~10.0.6", - "expo-file-system": "~17.0.1", - "expo-font": "~12.0.4", - "expo-keep-awake": "~13.0.1", - "expo-modules-autolinking": "1.11.1", - "expo-modules-core": "1.12.10", - "fbemitter": "^3.0.0", - "whatwg-url-without-unicode": "8.0.0-3" + "node_modules/expect/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" }, - "bin": { - "expo": "bin/cli" + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "node_modules/expo-asset": { - "version": "10.0.6", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-10.0.6.tgz", - "integrity": "sha512-waP73/ccn/HZNNcGM4/s3X3icKjSSbEQ9mwc6tX34oYNg+XE5WdwOuZ9wgVVFrU7wZMitq22lQXd2/O0db8bxg==", - "optional": true, - "peer": true, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", "dependencies": { - "@react-native/assets-registry": "~0.74.83", - "expo-constants": "~16.0.0", - "invariant": "^2.2.4", - "md5-file": "^3.2.3" - }, - "peerDependencies": { - "expo": "*" + "is-callable": "^1.1.3" } }, - "node_modules/expo-asset/node_modules/@react-native/assets-registry": { - "version": "0.74.83", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.74.83.tgz", - "integrity": "sha512-2vkLMVnp+YTZYTNSDIBZojSsjz8sl5PscP3j4GcV6idD8V978SZfwFlk8K0ti0BzRs11mzL0Pj17km597S/eTQ==", - "optional": true, - "peer": true, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, "engines": { - "node": ">=18" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/expo-constants": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.1.tgz", - "integrity": "sha512-s6aTHtglp926EsugWtxN7KnpSsE9FCEjb7CgEjQQ78Gpu4btj4wB+IXot2tlqNwqv+x7xFe5veoPGfJDGF/kVg==", - "optional": true, - "peer": true, - "dependencies": { - "@expo/config": "~9.0.0-beta.0" + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" }, - "peerDependencies": { - "expo": "*" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/expo-file-system": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-17.0.1.tgz", - "integrity": "sha512-dYpnZJqTGj6HCYJyXAgpFkQWsiCH3HY1ek2cFZVHFoEc5tLz9gmdEgTF6nFHurvmvfmXqxi7a5CXyVm0aFYJBw==", - "optional": true, - "peer": true, - "peerDependencies": { - "expo": "*" - } + "node_modules/fparser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fparser/-/fparser-4.2.0.tgz", + "integrity": "sha512-Z+YUaZfZaaRyzWpu5baHNk514HKVN/iwkrtrCdkHs5rbMWYMNk6l3C9SMTLjmgEqTCn8Ff13zMDq3uyxngDBog==", + "license": "MIT" }, - "node_modules/expo-font": { - "version": "12.0.4", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-12.0.4.tgz", - "integrity": "sha512-VtOQB7MEeFMVwo46/9/ntqzrgraTE7gAsnfi2NukFcCpDmyAU3G1R7m287LUXltE46SmGkMgAvM6+fflXFjaJA==", - "optional": true, - "peer": true, - "dependencies": { - "fontfaceobserver": "^2.1.0" - }, - "peerDependencies": { - "expo": "*" - } + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, - "node_modules/expo-keep-awake": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-13.0.1.tgz", - "integrity": "sha512-Kqv8Bf1f5Jp7YMUgTTyKR9GatgHJuAcC8vVWDEkgVhB3O7L3pgBy5MMSMUhkTmRRV6L8TZe/rDmjiBoVS/soFA==", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, "optional": true, - "peer": true, - "peerDependencies": { - "expo": "*" + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/expo-modules-autolinking": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-0.0.3.tgz", - "integrity": "sha512-azkCRYj/DxbK4udDuDxA9beYzQTwpJ5a9QA0bBgha2jHtWdFGF4ZZWSY+zNA5mtU3KqzYt8jWHfoqgSvKyu1Aw==", - "optional": true, - "dependencies": { - "chalk": "^4.1.0", - "commander": "^7.2.0", - "fast-glob": "^3.2.5", - "find-up": "~5.0.0", - "fs-extra": "^9.1.0" - }, - "bin": { - "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/expo-modules-autolinking/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "optional": true, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/expo-modules-core": { - "version": "1.12.10", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.12.10.tgz", - "integrity": "sha512-aS4imfr7fuUtcx+j/CHuG6ohNSThyCzGRh1kKjQTDcO0/CqDO2cSFnxf7n2vpiRFgyoMFJvFFtW/zIzVXiC2Tw==", - "optional": true, - "peer": true, - "dependencies": { - "invariant": "^2.2.4" + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/expo-random": { - "version": "13.4.0", - "resolved": "https://registry.npmjs.org/expo-random/-/expo-random-13.4.0.tgz", - "integrity": "sha512-Z/Bbd+1MbkK8/4ukspgA3oMlcu0q3YTCu//7q2xHwy35huN6WCv4/Uw2OGyCiOQjAbU02zwq6swA+VgVmJRCEw==", - "optional": true, - "dependencies": { - "base64-js": "^1.3.0" - }, - "peerDependencies": { - "expo": "*" + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/expo/node_modules/expo-modules-autolinking": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-1.11.1.tgz", - "integrity": "sha512-2dy3lTz76adOl7QUvbreMCrXyzUiF8lygI7iFJLjgIQIVH+43KnFWE5zBumpPbkiaq0f0uaFpN9U0RGQbnKiMw==", - "optional": true, - "peer": true, - "dependencies": { - "chalk": "^4.1.0", - "commander": "^7.2.0", - "fast-glob": "^3.2.5", - "find-up": "^5.0.0", - "fs-extra": "^9.1.0" - }, - "bin": { - "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/expo/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "optional": true, - "peer": true, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "optional": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8.6.0" + "node": ">=8.0.0" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "optional": true, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dependencies": { - "is-glob": "^4.0.1" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">= 6" + "node": ">= 0.4" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fast-xml-parser": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.7.tgz", - "integrity": "sha512-J8r6BriSLO1uj2miOk1NW0YVm8AGOOu3Si2HQp/cSmo6EA4m3fcwu2WKjJ4RK9wMLBtg69y1kS8baDiQBR41Ig==", - "funding": [ - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" - }, - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "optional": true, - "peer": true, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dependencies": { - "strnum": "^1.0.5" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "devOptional": true, - "dependencies": { - "reusify": "^1.0.4" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "optional": true, - "peer": true, - "dependencies": { - "bser": "2.1.1" - } + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, - "node_modules/fbemitter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz", - "integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==", - "optional": true, - "peer": true, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, "dependencies": { - "fbjs": "^3.0.0" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fbjs": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", - "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", - "optional": true, - "peer": true, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "dependencies": { - "cross-fetch": "^3.1.5", - "fbjs-css-vars": "^1.0.0", - "loose-envify": "^1.0.0", - "object-assign": "^4.1.0", - "promise": "^7.1.1", - "setimmediate": "^1.0.5", - "ua-parser-js": "^1.0.35" + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" } }, - "node_modules/fbjs-css-vars": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", - "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", - "optional": true, - "peer": true - }, - "node_modules/fetch-retry": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-4.1.1.tgz", - "integrity": "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA==", - "optional": true, - "peer": true - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", "dependencies": { - "flat-cache": "^3.0.4" + "define-properties": "^1.1.3" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "optional": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "optional": true, - "peer": true, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" + "function-bind": "^1.1.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4.0" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true - }, - "node_modules/finalhandler/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "optional": true, - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/find-cache-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", - "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", - "optional": true, - "peer": true, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^2.0.0", - "pkg-dir": "^3.0.0" + "es-define-property": "^1.0.0" }, - "engines": { - "node": ">=6" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/find-cache-dir/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "optional": true, - "peer": true, - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/find-cache-dir/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver" + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "devOptional": true, + "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==", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "has-symbols": "^1.0.3" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-yarn-workspace-root": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", - "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", - "optional": true, - "peer": true, - "dependencies": { - "micromatch": "^4.0.2" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "function-bind": "^1.1.2" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">= 0.4" } }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" - }, - "node_modules/flow-enums-runtime": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.5.tgz", - "integrity": "sha512-PSZF9ZuaZD03sT9YaIs0FrGJ7lSUw7rHZIex+73UYVXg46eL/wxN5PaVcPJFudE2cJu5f0fezitV5aBkLHPUOQ==", - "optional": true, - "peer": true + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" }, - "node_modules/flow-parser": { - "version": "0.206.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.206.0.tgz", - "integrity": "sha512-HVzoK3r6Vsg+lKvlIZzaWNBVai+FXTX1wdYhz/wVlH13tb/gOdLXmlTqy6odmTBhT5UoWUbq0k8263Qhr9d88w==", - "optional": true, - "peer": true, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, "engines": { - "node": ">=0.4.0" + "node": ">=10.17.0" } }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "funding": [ { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } - ], + ] + }, + "node_modules/ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "node": ">= 4" } }, - "node_modules/fontfaceobserver": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", - "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", - "optional": true, - "peer": true + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/import-local/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">= 6" + "node": ">=8" } }, - "node_modules/fparser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fparser/-/fparser-3.1.0.tgz", - "integrity": "sha512-P9hS9RjO7l4JvWHcDUqos0BXAGzJN4WwJBCh7gwja/23TuW7jfpOKZ+jlGoYp4ZUDnbAJ+rDyKLkIJFCLzgZ+w==" - }, - "node_modules/freeport-async": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", - "integrity": "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==", - "optional": true, - "peer": true, + "node_modules/import-local/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, "engines": { "node": ">=8" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "optional": true, - "peer": true, + "node_modules/import-local/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "node_modules/fs-extra": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", - "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "node_modules/import-local/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "p-limit": "^2.2.0" }, "engines": { - "node": ">=14.14" + "node": ">=8" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "node_modules/import-local/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", "dependencies": { - "minipass": "^3.0.0" + "find-up": "^4.0.0" }, "engines": { - "node": ">= 8" + "node": ">=8" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "devOptional": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=0.8.19" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ] }, - "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "once": "^1.3.0", + "wrappy": "1" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - } + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "optional": true, - "peer": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "hasown": "^2.0.0", + "side-channel": "^1.0.4" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", - "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", - "optional": true, - "peer": true, - "dependencies": { - "pump": "^3.0.0" }, - "engines": { - "node": ">=6" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dependencies": { - "call-bind": "^1.0.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -9330,75 +5754,67 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/getenv": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz", - "integrity": "sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg==", - "optional": true, - "peer": true, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-arrow-function": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-arrow-function/-/is-arrow-function-2.0.3.tgz", + "integrity": "sha512-iDStzcT1FJMzx+TjCOK//uDugSe/Mif/8a+T0htydQ3qkJGvSweTZpVYz4hpJH0baloSPiAFQdA8WslAgJphvQ==", + "dependencies": { + "is-callable": "^1.0.4" + }, "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, - "node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "devOptional": true, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "has-tostringtag": "^1.0.0" }, "engines": { - "node": "*" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", "dependencies": { - "is-glob": "^4.0.3" + "has-bigints": "^1.0.1" }, - "engines": { - "node": ">=10.13.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", "dependencies": { - "type-fest": "^0.20.2" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "dependencies": { - "define-properties": "^1.1.3" - }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "engines": { "node": ">= 0.4" }, @@ -9406,31 +5822,24 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "optional": true, - "peer": true, + "node_modules/is-core-module": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", + "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" + "has": "^1.0.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dependencies": { + "is-typed-array": "^1.1.13" + }, "engines": { "node": ">= 0.4" }, @@ -9438,96 +5847,122 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/graphql": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz", - "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==", - "optional": true, - "peer": true, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, "engines": { - "node": ">= 10.x" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graphql-tag": { - "version": "2.12.6", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", - "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", - "optional": true, - "peer": true, + "node_modules/is-equal": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/is-equal/-/is-equal-1.6.4.tgz", + "integrity": "sha512-NiPOTBb5ahmIOYkJ7mVTvvB1bydnTzixvfO+59AjJKBpyjPBIULL3EHGxySyZijlVpewveJyhiLQThcivkkAtw==", "dependencies": { - "tslib": "^2.1.0" + "es-get-iterator": "^1.1.2", + "functions-have-names": "^1.2.2", + "has": "^1.0.3", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "is-arrow-function": "^2.0.3", + "is-bigint": "^1.0.4", + "is-boolean-object": "^1.1.2", + "is-callable": "^1.2.4", + "is-date-object": "^1.0.5", + "is-generator-function": "^1.0.10", + "is-number-object": "^1.0.6", + "is-regex": "^1.1.4", + "is-string": "^1.0.7", + "is-symbol": "^1.0.4", + "isarray": "^2.0.5", + "object-inspect": "^1.12.0", + "object.entries": "^1.1.5", + "object.getprototypeof": "^1.0.3", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, - "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "engines": { - "node": ">= 0.4.0" + "node": ">=0.10.0" } }, - "node_modules/has-bigints": { + "node_modules/is-finalizationregistry": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dependencies": { + "call-bind": "^1.0.2" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", "dependencies": { - "es-define-property": "^1.0.0" + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "engines": { - "node": ">= 0.4" + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "engines": { "node": ">= 0.4" }, @@ -9535,12 +5970,12 @@ "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==", + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", "dependencies": { - "has-symbols": "^1.0.3" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -9549,1199 +5984,1282 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "optional": true - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", "dependencies": { - "function-bind": "^1.1.2" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hermes-estree": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.12.0.tgz", - "integrity": "sha512-+e8xR6SCen0wyAKrMT3UD0ZCCLymKhRgjEB5sS28rKiFir/fXgLoeRilRUssFCILmGHb+OvHDUlhxs0+IEyvQw==", - "optional": true, - "peer": true - }, - "node_modules/hermes-parser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.12.0.tgz", - "integrity": "sha512-d4PHnwq6SnDLhYl3LHNHvOg7nQ6rcI7QVil418REYksv0Mh3cEkHDcuhGxNQ3vgnLSLl4QSvDrFCwQNYdpWlzw==", - "optional": true, - "peer": true, - "dependencies": { - "hermes-estree": "0.12.0" + "node_modules/is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hermes-profile-transformer": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/hermes-profile-transformer/-/hermes-profile-transformer-0.0.6.tgz", - "integrity": "sha512-cnN7bQUm65UWOy6cbGcCcZ3rpwW8Q/j4OP5aWRhEry4Z2t2aR1cjrbp0BS+KiBN0smvP1caBgAuxutvyvJILzQ==", - "optional": true, - "peer": true, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "dependencies": { - "source-map": "^0.7.3" + "call-bind": "^1.0.7" }, "engines": { - "node": ">=8" - } - }, - "node_modules/hosted-git-info": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz", - "integrity": "sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw==", - "optional": true, - "peer": true, - "dependencies": { - "lru-cache": "^6.0.0" + "node": ">= 0.4" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "optional": true, - "peer": true, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", "dependencies": { - "yallist": "^4.0.0" + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true, - "peer": true - }, - "node_modules/html-entities": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", - "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ] - }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "optional": true - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "optional": true, - "peer": true, - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "node": ">= 0.4" }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "optional": true, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" + "has-symbols": "^1.0.2" }, "engines": { - "node": ">= 6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "optional": true, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dependencies": { - "agent-base": "6", - "debug": "4" + "which-typed-array": "^1.1.14" }, "engines": { - "node": ">= 6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10.17.0" + "node_modules/is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "optional": true, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", "dependencies": { - "ms": "^2.0.0" + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "node_modules/is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" }, - "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", - "devOptional": true, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", "engines": { - "node": ">= 4" + "node": ">=8" } }, - "node_modules/image-size": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz", - "integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==", - "optional": true, - "peer": true, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "queue": "6.0.2" - }, - "bin": { - "image-size": "bin/image-size.js" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=10" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, "engines": { - "node": ">=0.8.19" + "node": ">=10" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "optional": true, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, "engines": { "node": ">=8" } }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "optional": true - }, - "node_modules/inflection": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", - "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", - "engines": [ - "node >= 0.4.0" - ] - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "devOptional": true, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "node_modules/inquirer": { - "version": "8.2.7", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", - "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", - "dependencies": { - "@inquirer/external-editor": "^1.0.0", - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" + "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">=12.0.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/inquirer/node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" }, "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@types/node": ">=18" + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "peerDependenciesMeta": { - "@types/node": { + "node-notifier": { "optional": true } } }, - "node_modules/inquirer/node_modules/@types/node": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", - "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/inquirer/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "optional": true, - "peer": true - }, - "node_modules/internal-ip": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz", - "integrity": "sha512-S1zBo1D6zcsyuC6PMmY5+55YMILQ9av8lotMx447Bq6SAgo/sDK6y6uUKmuYhW7eacnIhFfsPmCNYdDzsnnDCg==", - "optional": true, - "peer": true, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", "dependencies": { - "default-gateway": "^4.2.0", - "ipaddr.js": "^1.9.0" + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "node_modules/jest-changed-files/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "optional": true, + "node_modules/jest-changed-files/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/ip": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", - "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", - "optional": true, - "peer": true - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "optional": true, + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, "engines": { - "node": ">= 12" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/ip-regex": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", - "integrity": "sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } + "node_modules/jest-changed-files/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.10" + "node_modules/jest-changed-files/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, + "node_modules/jest-changed-files/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "node_modules/jest-changed-files/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "optional": true, - "peer": true - }, - "node_modules/is-arrow-function": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-arrow-function/-/is-arrow-function-2.0.3.tgz", - "integrity": "sha512-iDStzcT1FJMzx+TjCOK//uDugSe/Mif/8a+T0htydQ3qkJGvSweTZpVYz4hpJH0baloSPiAFQdA8WslAgJphvQ==", - "dependencies": { - "is-callable": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/is-async-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", - "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, + "node_modules/jest-changed-files/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dependencies": { - "has-bigints": "^1.0.1" + "node_modules/jest-changed-files/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "node_modules/jest-changed-files/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "optional": true, - "peer": true + "node_modules/jest-changed-files/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/jest-changed-files/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/is-core-module": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", - "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", - "optional": true, - "peer": true, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", "dependencies": { - "has": "^1.0.3" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "node_modules/jest-circus/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "is-typed-array": "^1.1.13" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6.9.0" } }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "node_modules/jest-circus/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-directory": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", - "integrity": "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw==", - "optional": true, - "peer": true, + "node_modules/jest-circus/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "optional": true, - "peer": true, - "bin": { - "is-docker": "cli.js" + "node_modules/jest-circus/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-equal": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/is-equal/-/is-equal-1.6.4.tgz", - "integrity": "sha512-NiPOTBb5ahmIOYkJ7mVTvvB1bydnTzixvfO+59AjJKBpyjPBIULL3EHGxySyZijlVpewveJyhiLQThcivkkAtw==", + "node_modules/jest-circus/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "es-get-iterator": "^1.1.2", - "functions-have-names": "^1.2.2", - "has": "^1.0.3", - "has-bigints": "^1.0.1", - "has-symbols": "^1.0.2", - "is-arrow-function": "^2.0.3", - "is-bigint": "^1.0.4", - "is-boolean-object": "^1.1.2", - "is-callable": "^1.2.4", - "is-date-object": "^1.0.5", - "is-generator-function": "^1.0.10", - "is-number-object": "^1.0.6", - "is-regex": "^1.1.4", - "is-string": "^1.0.7", - "is-symbol": "^1.0.4", - "isarray": "^2.0.5", - "object-inspect": "^1.12.0", - "object.entries": "^1.1.5", - "object.getprototypeof": "^1.0.3", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, - "engines": { - "node": ">=0.10.0" + "node_modules/jest-circus/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-circus/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" } }, - "node_modules/is-finalizationregistry": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", - "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "node_modules/jest-circus/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2" + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-circus/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "optional": true, - "peer": true, + "node_modules/jest-circus/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "node_modules/jest-circus/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "node_modules/jest-circus/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "node_modules/jest-circus/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/is-invalid-path": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-invalid-path/-/is-invalid-path-0.1.0.tgz", - "integrity": "sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==", - "optional": true, - "peer": true, + "node_modules/jest-circus/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "is-glob": "^2.0.0" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-invalid-path/node_modules/is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==", - "optional": true, - "peer": true, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/is-invalid-path/node_modules/is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==", - "optional": true, - "peer": true, + "node_modules/jest-cli/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "is-extglob": "^1.0.0" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "optional": true + "node_modules/jest-cli/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/is-map": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", - "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node_modules/jest-cli/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "node_modules/jest-cli/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "optional": true, + "node_modules/jest-cli/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">=0.12.0" + "node": ">=8" } }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "node_modules/jest-cli/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-path-cwd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", - "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", - "optional": true, - "peer": true, + "node_modules/jest-cli/node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "devOptional": true, + "node_modules/jest-cli/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "optional": true, - "peer": true, + "node_modules/jest-cli/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "isobject": "^3.0.1" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", - "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "node_modules/jest-config/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6.9.0" } }, - "node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", - "optional": true, - "peer": true, + "node_modules/jest-config/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "node_modules/jest-config/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "node_modules/jest-config/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "node_modules/jest-config/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.14" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "node_modules/jest-config/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-config/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/jest-config/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-config/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-valid-path": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz", - "integrity": "sha512-+kwPrVDu9Ms03L90Qaml+79+6DZHqHyRoANI6IsZJ/g8frhnfchDOBCa0RbQ6/kdHt5CS5OeIEyrYznNuVN+8A==", - "optional": true, - "peer": true, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", "dependencies": { - "is-invalid-path": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" + "balanced-match": "^1.0.0" } }, - "node_modules/is-weakmap": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", - "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node_modules/jest-config/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "node_modules/jest-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", "dependencies": { - "call-bind": "^1.0.2" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", - "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "optional": true, - "peer": true, + "node_modules/jest-config/node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", "dependencies": { - "is-docker": "^2.0.0" + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "optional": true, - "peer": true, + "node_modules/jest-config/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/isomorphic-webcrypto": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/isomorphic-webcrypto/-/isomorphic-webcrypto-2.3.8.tgz", - "integrity": "sha512-XddQSI0WYlSCjxtm1AI8kWQOulf7hAN3k3DclF1sxDJZqOe0pcsOt675zvWW91cZH9hYs3nlA3Ev8QK5i80SxQ==", + "node_modules/jest-config/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", "dependencies": { - "@peculiar/webcrypto": "^1.0.22", - "asmcrypto.js": "^0.22.0", - "b64-lite": "^1.3.1", - "b64u-lite": "^1.0.1", - "msrcrypto": "^1.5.6", - "str2buf": "^1.3.0", - "webcrypto-shim": "^0.1.4" + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" }, - "optionalDependencies": { - "@unimodules/core": "*", - "@unimodules/react-native-adapter": "*", - "expo-random": "*", - "react-native-securerandom": "^0.1.1" - } - }, - "node_modules/iterate-iterator": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.2.tgz", - "integrity": "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/iterate-value": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz", - "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==", - "dependencies": { - "es-get-iterator": "^1.0.2", - "iterate-iterator": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node_modules/jest-config/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "optional": true, - "peer": true, + "node_modules/jest-config/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-node/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "optional": true, - "peer": true, + "node_modules/jest-config/node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-node/node_modules/@types/yargs": { - "version": "17.0.24", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", - "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", - "optional": true, - "peer": true, + "node_modules/jest-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", "dependencies": { - "@types/yargs-parser": "*" + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "optional": true, - "peer": true, + "node_modules/jest-config/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "node_modules/jest-config/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-message-util/node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/highlight": "^7.22.13", - "chalk": "^2.4.2" - }, + "node_modules/jest-config/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jest-message-util/node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "optional": true, - "peer": true, + "node_modules/jest-config/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "optional": true, - "peer": true, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" }, "engines": { - "node": ">=4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "optional": true, - "peer": true, + "node_modules/jest-diff/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/@types/yargs": { - "version": "17.0.24", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", - "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", - "optional": true, - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" - } + "node_modules/jest-diff/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/jest-message-util/node_modules/ansi-styles": { + "node_modules/jest-diff/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "optional": true, - "peer": true, + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -10749,2014 +7267,1952 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-message-util/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "optional": true, - "peer": true, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/jest-message-util/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "optional": true, - "peer": true - }, - "node_modules/jest-message-util/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/jest-message-util/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "optional": true, - "peer": true, + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, "engines": { - "node": ">=4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "optional": true, - "peer": true, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "detect-newline": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "optional": true, - "peer": true - }, - "node_modules/jest-message-util/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "optional": true, - "peer": true, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { - "node": ">=4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "optional": true, - "peer": true, + "node_modules/jest-each/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-mock/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "optional": true, - "peer": true, + "node_modules/jest-each/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-mock/node_modules/@types/yargs": { - "version": "17.0.24", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", - "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", - "optional": true, - "peer": true, + "node_modules/jest-each/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-each/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } }, - "node_modules/jest-regex-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", - "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", - "optional": true, - "peer": true, + "node_modules/jest-each/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, + "node_modules/jest-each/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-util/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "optional": true, - "peer": true, + "node_modules/jest-each/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", + "@jest/types": "30.4.1", "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-util/node_modules/@types/yargs": { - "version": "17.0.24", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", - "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", - "optional": true, - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" + "node_modules/jest-each/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "optional": true, - "peer": true, + "node_modules/jest-each/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-validate/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "optional": true, - "peer": true, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", + "@jest/types": "30.4.1", "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/@types/yargs": { - "version": "17.0.24", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", - "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", - "optional": true, - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "optionalDependencies": { + "fsevents": "^2.3.3" } }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "optional": true, - "peer": true, + "node_modules/jest-haste-map/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "optional": true, - "peer": true - }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "optional": true, - "peer": true, + "node_modules/jest-haste-map/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">= 10.13.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "optional": true, - "peer": true, + "node_modules/jest-haste-map/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-haste-map/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" - }, + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-haste-map/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=8" } }, - "node_modules/jimp-compact": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", - "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", - "optional": true, - "peer": true + "node_modules/jest-haste-map/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } }, - "node_modules/joi": { - "version": "17.10.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.10.1.tgz", - "integrity": "sha512-vIiDxQKmRidUVp8KngT8MZSOcmRVm2zV7jbMjNYWuHcJWI0bUck3nRTGQjhpPlQenIQIBC5Vp9AhcnHbWQqafw==", - "optional": true, - "peer": true, + "node_modules/jest-haste-map/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/join-component": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/join-component/-/join-component-1.1.0.tgz", - "integrity": "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==", - "optional": true, - "peer": true - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "optional": true - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "devOptional": true, + "node_modules/jest-haste-map/node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jsc-android": { - "version": "250231.0.0", - "resolved": "https://registry.npmjs.org/jsc-android/-/jsc-android-250231.0.0.tgz", - "integrity": "sha512-rS46PvsjYmdmuz1OAWXY/1kCYG7pnf1TBqeTiOJr1iDz7s5DLxxC9n/ZMknLDxzYzNVfI7R95MH10emSSG1Wuw==", - "optional": true, - "peer": true - }, - "node_modules/jsc-safe-url": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", - "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", - "optional": true, - "peer": true - }, - "node_modules/jscodeshift": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-0.14.0.tgz", - "integrity": "sha512-7eCC1knD7bLUPuSCwXsMZUH51O8jIcoVyKtI6P0XM0IVzlGjckPy3FIwQlorzbN0Sg79oK+RlohN32Mqf/lrYA==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.13.16", - "@babel/parser": "^7.13.16", - "@babel/plugin-proposal-class-properties": "^7.13.0", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8", - "@babel/plugin-proposal-optional-chaining": "^7.13.12", - "@babel/plugin-transform-modules-commonjs": "^7.13.8", - "@babel/preset-flow": "^7.13.13", - "@babel/preset-typescript": "^7.13.0", - "@babel/register": "^7.13.16", - "babel-core": "^7.0.0-bridge.0", - "chalk": "^4.1.2", - "flow-parser": "0.*", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.4", - "neo-async": "^2.5.0", - "node-dir": "^0.1.17", - "recast": "^0.21.0", - "temp": "^0.8.4", - "write-file-atomic": "^2.3.0" - }, - "bin": { - "jscodeshift": "bin/jscodeshift.js" + "node_modules/jest-haste-map/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, - "peerDependencies": { - "@babel/preset-env": "^7.1.6" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "optional": true, - "peer": true, - "bin": { - "jsesc": "bin/jsesc" + "node_modules/jest-haste-map/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "optional": true, - "peer": true - }, - "node_modules/json-schema-deref-sync": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/json-schema-deref-sync/-/json-schema-deref-sync-0.13.0.tgz", - "integrity": "sha512-YBOEogm5w9Op337yb6pAT6ZXDqlxAsQCanM3grid8lMWNxRJO/zWEJi3ZzqDL8boWfwhTFym5EFrNgWwpqcBRg==", - "optional": true, - "peer": true, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", "dependencies": { - "clone": "^2.1.2", - "dag-map": "~1.0.0", - "is-valid-path": "^0.1.1", - "lodash": "^4.17.13", - "md5": "~2.2.0", - "memory-cache": "~0.2.0", - "traverse": "~0.6.6", - "valid-url": "~1.0.9" + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" }, "engines": { - "node": ">=6.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/json-schema-deref-sync/node_modules/md5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", - "integrity": "sha512-PlGG4z5mBANDGCKsYQe0CaUYHdZYZt8ZPZLmEt+Urf0W4GlpTX4HescwHU+dc9+Z/G/vZKYZYFrwgm9VxK6QOQ==", - "optional": true, - "peer": true, + "node_modules/jest-leak-detector/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "charenc": "~0.0.1", - "crypt": "~0.0.1", - "is-buffer": "~1.1.1" + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "node_modules/jest-leak-detector/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "optional": true, - "peer": true, - "bin": { - "json5": "lib/cli.js" - }, + "node_modules/jest-leak-detector/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "node_modules/jest-leak-detector/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "universalify": "^2.0.0" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, + "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "optional": true, - "peer": true, + "node_modules/jest-matcher-utils/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "optional": true, - "peer": true, + "node_modules/jest-matcher-utils/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/klona": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", - "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, "engines": { - "node": ">= 8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "optional": true, - "peer": true, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, + "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" }, "engines": { - "node": ">= 0.8.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/lighthouse-logger": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", - "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", - "optional": true, - "peer": true, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", "dependencies": { - "debug": "^2.6.9", - "marky": "^1.2.2" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/lighthouse-logger/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" + "node_modules/jest-resolve-dependencies/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/lighthouse-logger/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true - }, - "node_modules/lightningcss": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.19.0.tgz", - "integrity": "sha512-yV5UR7og+Og7lQC+70DA7a8ta1uiOPnWPJfxa0wnxylev5qfo4P+4iMpzWAdYWOca4jdNQZii+bDL/l+4hUXIA==", - "optional": true, - "peer": true, + "node_modules/jest-resolve/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "detect-libc": "^1.0.3" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.19.0", - "lightningcss-darwin-x64": "1.19.0", - "lightningcss-linux-arm-gnueabihf": "1.19.0", - "lightningcss-linux-arm64-gnu": "1.19.0", - "lightningcss-linux-arm64-musl": "1.19.0", - "lightningcss-linux-x64-gnu": "1.19.0", - "lightningcss-linux-x64-musl": "1.19.0", - "lightningcss-win32-x64-msvc": "1.19.0" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.19.0.tgz", - "integrity": "sha512-wIJmFtYX0rXHsXHSr4+sC5clwblEMji7HHQ4Ub1/CznVRxtCFha6JIt5JZaNf8vQrfdZnBxLLC6R8pC818jXqg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.19.0.tgz", - "integrity": "sha512-Lif1wD6P4poaw9c/4Uh2z+gmrWhw/HtXFoeZ3bEsv6Ia4tt8rOJBdkfVaUJ6VXmpKHALve+iTyP2+50xY1wKPw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node_modules/jest-resolve/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-resolve/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.19.0.tgz", - "integrity": "sha512-P15VXY5682mTXaiDtbnLYQflc8BYb774j2R84FgDLJTN6Qp0ZjWEFyN1SPqyfTj2B2TFjRHRUvQSSZ7qN4Weig==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "node_modules/jest-resolve/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.19.0.tgz", - "integrity": "sha512-zwXRjWqpev8wqO0sv0M1aM1PpjHz6RVIsBcxKszIG83Befuh4yNysjgHVplF9RTU7eozGe3Ts7r6we1+Qkqsww==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" + "node_modules/jest-resolve/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } ], - "peer": true, + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=8" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.19.0.tgz", - "integrity": "sha512-vSCKO7SDnZaFN9zEloKSZM5/kC5gbzUjoJQ43BvUpyTFUX7ACs/mDfl2Eq6fdz2+uWhUh7vf92c4EaaP4udEtA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" + "node_modules/jest-resolve/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.19.0.tgz", - "integrity": "sha512-0AFQKvVzXf9byrXUq9z0anMGLdZJS+XSDqidyijI5njIwj6MdbvX2UZK/c4FfNmeRa2N/8ngTffoIuOUit5eIQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" + "node_modules/jest-resolve/node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.19.0.tgz", - "integrity": "sha512-SJoM8CLPt6ECCgSuWe+g0qo8dqQYVcPiW2s19dxkmSI5+Uu1GIRzyKA0b7QqmEXolA+oSJhQqCmJpzjY4CuZAg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "node_modules/jest-resolve/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.19.0.tgz", - "integrity": "sha512-C+VuUTeSUOAaBZZOPT7Etn/agx/MatzJzGRkeV+zEABmPuntv1zihncsi+AyGmjkkzq3wVedEy7h0/4S84mUtg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" + "node_modules/jest-resolve/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/lightningcss/node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "optional": true, - "peer": true, - "bin": { - "detect-libc": "bin/detect-libc.js" + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=0.10" + "node": ">=6.9.0" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "optional": true, - "peer": true - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "devOptional": true, + "node_modules/jest-runner/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "node_modules/jest-runner/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "optional": true, - "peer": true + "node_modules/jest-runner/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "node_modules/jest-runner/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" + "node_modules/jest-runner/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", - "optional": true, - "peer": true + "node_modules/jest-runner/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "node_modules/jest-runner/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-runner/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" - }, + "node_modules/jest-runner/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">=8.0" + "node": ">=8" } }, - "node_modules/logkitty": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", - "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", - "optional": true, - "peer": true, + "node_modules/jest-runner/node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-fragments": "^0.2.1", - "dayjs": "^1.8.15", - "yargs": "^15.1.0" + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, - "bin": { - "logkitty": "bin/logkitty.js" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/logkitty/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "optional": true, - "peer": true, + "node_modules/jest-runner/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/logkitty/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "optional": true, - "peer": true, + "node_modules/jest-runner/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/logkitty/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "optional": true, - "peer": true, + "node_modules/jest-runner/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/logkitty/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "optional": true, - "peer": true, + "node_modules/jest-runner/node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/logkitty/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "optional": true, - "peer": true, + "node_modules/jest-runner/node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/logkitty/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "optional": true, - "peer": true, + "node_modules/jest-runner/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/logkitty/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "optional": true, - "peer": true + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/logkitty/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "optional": true, - "peer": true, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runner/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/logkitty/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "optional": true, - "peer": true, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/long-timeout": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", - "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "optional": true, + "node_modules/jest-runtime/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, - "bin": { - "loose-envify": "cli.js" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "optional": true, - "peer": true, + "node_modules/jest-runtime/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/luxon": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.3.tgz", - "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==", + "node_modules/jest-runtime/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, "engines": { - "node": ">=12" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/magic-bytes.js": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==" - }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "optional": true, + "node_modules/jest-runtime/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">= 10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/make-fetch-happen/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "optional": true, + "node_modules/jest-runtime/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "yallist": "^4.0.0" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/make-fetch-happen/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true + "node_modules/jest-runtime/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "optional": true, - "peer": true, + "node_modules/jest-runtime/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "tmpl": "1.0.5" + "@sinonjs/commons": "^3.0.1" } }, - "node_modules/marky": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz", - "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==", - "optional": true, - "peer": true + "node_modules/jest-runtime/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/jest-runtime/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "optional": true, - "peer": true, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" + "balanced-match": "^1.0.0" } }, - "node_modules/md5-file": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-3.2.3.tgz", - "integrity": "sha512-3Tkp1piAHaworfcCgH0jKbTvj1jWWFgbvh2cXaNCgHwyTCBxxvD1Y04rmfpvdPm1P4oXMOpm6+2H7sr7v9v8Fw==", - "optional": true, - "peer": true, + "node_modules/jest-runtime/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", "dependencies": { - "buffer-alloc": "^1.1.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, "bin": { - "md5-file": "cli.js" + "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=0.10" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/md5hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/md5hex/-/md5hex-1.0.0.tgz", - "integrity": "sha512-c2YOUbp33+6thdCUi34xIyOU/a7bvGKj/3DB1iaPMTuPHf/Q2d5s4sn1FaCOO43XkXggnb08y5W2PU8UNYNLKQ==", - "optional": true, - "peer": true - }, - "node_modules/memoize-one": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", - "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", - "optional": true, - "peer": true - }, - "node_modules/memory-cache": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", - "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==", - "optional": true, - "peer": true - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "optional": true, - "peer": true - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "optional": true, + "node_modules/jest-runtime/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, "engines": { - "node": ">= 8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro/-/metro-0.76.7.tgz", - "integrity": "sha512-67ZGwDeumEPnrHI+pEDSKH2cx+C81Gx8Mn5qOtmGUPm/Up9Y4I1H2dJZ5n17MWzejNo0XAvPh0QL0CrlJEODVQ==", - "optional": true, - "peer": true, + "node_modules/jest-runtime/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.0.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.0", - "@babel/parser": "^7.20.0", - "@babel/template": "^7.0.0", - "@babel/traverse": "^7.20.0", - "@babel/types": "^7.20.0", - "accepts": "^1.3.7", - "async": "^3.2.2", - "chalk": "^4.0.0", - "ci-info": "^2.0.0", - "connect": "^3.6.5", - "debug": "^2.2.0", - "denodeify": "^1.2.1", - "error-stack-parser": "^2.0.6", - "graceful-fs": "^4.2.4", - "hermes-parser": "0.12.0", - "image-size": "^1.0.2", - "invariant": "^2.2.4", - "jest-worker": "^27.2.0", - "jsc-safe-url": "^0.2.2", - "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.76.7", - "metro-cache": "0.76.7", - "metro-cache-key": "0.76.7", - "metro-config": "0.76.7", - "metro-core": "0.76.7", - "metro-file-map": "0.76.7", - "metro-inspector-proxy": "0.76.7", - "metro-minify-terser": "0.76.7", - "metro-minify-uglify": "0.76.7", - "metro-react-native-babel-preset": "0.76.7", - "metro-resolver": "0.76.7", - "metro-runtime": "0.76.7", - "metro-source-map": "0.76.7", - "metro-symbolicate": "0.76.7", - "metro-transform-plugins": "0.76.7", - "metro-transform-worker": "0.76.7", - "mime-types": "^2.1.27", - "node-fetch": "^2.2.0", - "nullthrows": "^1.1.1", - "rimraf": "^3.0.2", - "serialize-error": "^2.1.0", - "source-map": "^0.5.6", - "strip-ansi": "^6.0.0", - "throat": "^5.0.0", - "ws": "^7.5.1", - "yargs": "^17.6.2" - }, - "bin": { - "metro": "src/cli.js" + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-babel-transformer": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.76.7.tgz", - "integrity": "sha512-bgr2OFn0J4r0qoZcHrwEvccF7g9k3wdgTOgk6gmGHrtlZ1Jn3oCpklW/DfZ9PzHfjY2mQammKTc19g/EFGyOJw==", - "optional": true, - "peer": true, + "node_modules/jest-runtime/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.20.0", - "hermes-parser": "0.12.0", - "nullthrows": "^1.1.1" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-cache": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.76.7.tgz", - "integrity": "sha512-nWBMztrs5RuSxZRI7hgFgob5PhYDmxICh9FF8anm9/ito0u0vpPvRxt7sRu8fyeD2AHdXqE7kX32rWY0LiXgeg==", - "optional": true, - "peer": true, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", "dependencies": { - "metro-core": "0.76.7", - "rimraf": "^3.0.2" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=16" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/metro-cache-key": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.76.7.tgz", - "integrity": "sha512-0pecoIzwsD/Whn/Qfa+SDMX2YyasV0ndbcgUFx7w1Ct2sLHClujdhQ4ik6mvQmsaOcnGkIyN0zcceMDjC2+BFQ==", - "optional": true, - "peer": true, + "node_modules/jest-runtime/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/metro-config": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.76.7.tgz", - "integrity": "sha512-CFDyNb9bqxZemiChC/gNdXZ7OQkIwmXzkrEXivcXGbgzlt/b2juCv555GWJHyZSlorwnwJfY3uzAFu4A9iRVfg==", - "optional": true, - "peer": true, + "node_modules/jest-runtime/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-runtime/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "connect": "^3.6.5", - "cosmiconfig": "^5.0.5", - "jest-validate": "^29.2.1", - "metro": "0.76.7", - "metro-cache": "0.76.7", - "metro-core": "0.76.7", - "metro-runtime": "0.76.7" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-config/node_modules/metro-runtime": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.76.7.tgz", - "integrity": "sha512-MuWHubQHymUWBpZLwuKZQgA/qbb35WnDAKPo83rk7JRLIFPvzXSvFaC18voPuzJBt1V98lKQIonh6MiC9gd8Ug==", - "optional": true, - "peer": true, + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.0.0", - "react-refresh": "^0.4.0" + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-core": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.76.7.tgz", - "integrity": "sha512-0b8KfrwPmwCMW+1V7ZQPkTy2tsEKZjYG9Pu1PTsu463Z9fxX7WaR0fcHFshv+J1CnQSUTwIGGjbNvj1teKe+pw==", - "optional": true, - "peer": true, + "node_modules/jest-snapshot/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", "dependencies": { - "lodash.throttle": "^4.1.1", - "metro-resolver": "0.76.7" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=16" + "node": ">=6.9.0" } }, - "node_modules/metro-file-map": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.76.7.tgz", - "integrity": "sha512-s+zEkTcJ4mOJTgEE2ht4jIo1DZfeWreQR3tpT3gDV/Y/0UQ8aJBTv62dE775z0GLsWZApiblAYZsj7ZE8P06nw==", - "optional": true, - "peer": true, - "dependencies": { - "anymatch": "^3.0.3", - "debug": "^2.2.0", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.4", - "invariant": "^2.2.4", - "jest-regex-util": "^27.0.6", - "jest-util": "^27.2.0", - "jest-worker": "^27.2.0", - "micromatch": "^4.0.4", - "node-abort-controller": "^3.1.1", - "nullthrows": "^1.1.1", - "walker": "^1.0.7" + "node_modules/jest-snapshot/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-file-map/node_modules/@jest/types": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", - "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", - "optional": true, - "peer": true, + "node_modules/jest-snapshot/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "@types/yargs": "^16.0.0", - "chalk": "^4.0.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-file-map/node_modules/@types/yargs": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.5.tgz", - "integrity": "sha512-AxO/ADJOBFJScHbWhq2xAhlWP24rY4aCEG/NFaMvbT3X2MgRsLjhjQwsn0Zi5zn0LG9jUhCCZMeX9Dkuw6k+vQ==", - "optional": true, - "peer": true, + "node_modules/jest-snapshot/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-snapshot/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } }, - "node_modules/metro-file-map/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" + "node_modules/jest-snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/metro-file-map/node_modules/jest-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", - "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, + "node_modules/jest-snapshot/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=8" } }, - "node_modules/metro-file-map/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true - }, - "node_modules/metro-inspector-proxy": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-inspector-proxy/-/metro-inspector-proxy-0.76.7.tgz", - "integrity": "sha512-rNZ/6edTl/1qUekAhAbaFjczMphM50/UjtxiKulo6vqvgn/Mjd9hVqDvVYfAMZXqPvlusD88n38UjVYPkruLSg==", - "optional": true, - "peer": true, + "node_modules/jest-snapshot/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", "dependencies": { - "connect": "^3.6.5", - "debug": "^2.2.0", - "node-fetch": "^2.2.0", - "ws": "^7.5.1", - "yargs": "^17.6.2" - }, - "bin": { - "metro-inspector-proxy": "src/cli.js" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=16" - } - }, - "node_modules/metro-inspector-proxy/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-inspector-proxy/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true - }, - "node_modules/metro-inspector-proxy/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "optional": true, - "peer": true, + "node_modules/jest-snapshot/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "node-gyp-build": "^4.3.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=6.14.2" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-inspector-proxy/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node_modules/jest-snapshot/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/metro-minify-terser": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.76.7.tgz", - "integrity": "sha512-FQiZGhIxCzhDwK4LxyPMLlq0Tsmla10X7BfNGlYFK0A5IsaVKNJbETyTzhpIwc+YFRT4GkFFwgo0V2N5vxO5HA==", - "optional": true, - "peer": true, + "node_modules/jest-snapshot/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", "dependencies": { - "terser": "^5.15.0" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-minify-uglify": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-minify-uglify/-/metro-minify-uglify-0.76.7.tgz", - "integrity": "sha512-FuXIU3j2uNcSvQtPrAJjYWHruPiQ+EpE++J9Z+VznQKEHcIxMMoQZAfIF2IpZSrZYfLOjVFyGMvj41jQMxV1Vw==", - "optional": true, - "peer": true, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", "dependencies": { - "uglify-es": "^3.1.9" + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-react-native-babel-transformer": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-react-native-babel-transformer/-/metro-react-native-babel-transformer-0.76.7.tgz", - "integrity": "sha512-W6lW3J7y/05ph3c2p3KKJNhH0IdyxdOCbQ5it7aM2MAl0SM4wgKjaV6EYv9b3rHklpV6K3qMH37UKVcjMooWiA==", - "optional": true, - "peer": true, + "node_modules/jest-watcher/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.20.0", - "babel-preset-fbjs": "^3.4.0", - "hermes-parser": "0.12.0", - "metro-react-native-babel-preset": "0.76.7", - "nullthrows": "^1.1.1" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@babel/core": "*" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-react-native-babel-transformer/node_modules/metro-react-native-babel-preset": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.76.7.tgz", - "integrity": "sha512-R25wq+VOSorAK3hc07NW0SmN8z9S/IR0Us0oGAsBcMZnsgkbOxu77Mduqf+f4is/wnWHc5+9bfiqdLnaMngiVw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.20.0", - "@babel/plugin-proposal-async-generator-functions": "^7.0.0", - "@babel/plugin-proposal-class-properties": "^7.18.0", - "@babel/plugin-proposal-export-default-from": "^7.0.0", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.0", - "@babel/plugin-proposal-numeric-separator": "^7.0.0", - "@babel/plugin-proposal-object-rest-spread": "^7.20.0", - "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", - "@babel/plugin-proposal-optional-chaining": "^7.20.0", - "@babel/plugin-syntax-dynamic-import": "^7.8.0", - "@babel/plugin-syntax-export-default-from": "^7.0.0", - "@babel/plugin-syntax-flow": "^7.18.0", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.0.0", - "@babel/plugin-syntax-optional-chaining": "^7.0.0", - "@babel/plugin-transform-arrow-functions": "^7.0.0", - "@babel/plugin-transform-async-to-generator": "^7.20.0", - "@babel/plugin-transform-block-scoping": "^7.0.0", - "@babel/plugin-transform-classes": "^7.0.0", - "@babel/plugin-transform-computed-properties": "^7.0.0", - "@babel/plugin-transform-destructuring": "^7.20.0", - "@babel/plugin-transform-flow-strip-types": "^7.20.0", - "@babel/plugin-transform-function-name": "^7.0.0", - "@babel/plugin-transform-literals": "^7.0.0", - "@babel/plugin-transform-modules-commonjs": "^7.0.0", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.0.0", - "@babel/plugin-transform-parameters": "^7.0.0", - "@babel/plugin-transform-react-display-name": "^7.0.0", - "@babel/plugin-transform-react-jsx": "^7.0.0", - "@babel/plugin-transform-react-jsx-self": "^7.0.0", - "@babel/plugin-transform-react-jsx-source": "^7.0.0", - "@babel/plugin-transform-runtime": "^7.0.0", - "@babel/plugin-transform-shorthand-properties": "^7.0.0", - "@babel/plugin-transform-spread": "^7.0.0", - "@babel/plugin-transform-sticky-regex": "^7.0.0", - "@babel/plugin-transform-typescript": "^7.5.0", - "@babel/plugin-transform-unicode-regex": "^7.0.0", - "@babel/template": "^7.0.0", - "babel-plugin-transform-flow-enums": "^0.0.2", - "react-refresh": "^0.4.0" + "node_modules/jest-watcher/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@babel/core": "*" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-resolver": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.76.7.tgz", - "integrity": "sha512-pC0Wgq29HHIHrwz23xxiNgylhI8Rq1V01kQaJ9Kz11zWrIdlrH0ZdnJ7GC6qA0ErROG+cXmJ0rJb8/SW1Zp2IA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - } + "node_modules/jest-watcher/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/metro-runtime": { - "version": "0.76.8", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.76.8.tgz", - "integrity": "sha512-XKahvB+iuYJSCr3QqCpROli4B4zASAYpkK+j3a0CJmokxCDNbgyI4Fp88uIL6rNaZfN0Mv35S0b99SdFXIfHjg==", - "optional": true, - "peer": true, + "node_modules/jest-watcher/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.0.0", - "react-refresh": "^0.4.0" - }, - "engines": { - "node": ">=16" + "@types/yargs-parser": "*" } }, - "node_modules/metro-source-map": { - "version": "0.76.8", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.76.8.tgz", - "integrity": "sha512-Hh0ncPsHPVf6wXQSqJqB3K9Zbudht4aUtNpNXYXSxH+pteWqGAXnjtPsRAnCsCWl38wL0jYF0rJDdMajUI3BDw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/traverse": "^7.20.0", - "@babel/types": "^7.20.0", - "invariant": "^2.2.4", - "metro-symbolicate": "0.76.8", - "nullthrows": "^1.1.1", - "ob1": "0.76.8", - "source-map": "^0.5.6", - "vlq": "^1.0.0" - }, + "node_modules/jest-watcher/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=8" } }, - "node_modules/metro-source-map/node_modules/metro-symbolicate": { - "version": "0.76.8", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.76.8.tgz", - "integrity": "sha512-LrRL3uy2VkzrIXVlxoPtqb40J6Bf1mlPNmUQewipc3qfKKFgtPHBackqDy1YL0njDsWopCKcfGtFYLn0PTUn3w==", - "optional": true, - "peer": true, + "node_modules/jest-watcher/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", "dependencies": { - "invariant": "^2.2.4", - "metro-source-map": "0.76.8", - "nullthrows": "^1.1.1", - "source-map": "^0.5.6", - "through2": "^2.0.1", - "vlq": "^1.0.0" - }, - "bin": { - "metro-symbolicate": "src/index.js" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-source-map/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "optional": true, - "peer": true, + "node_modules/jest-watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/metro-symbolicate": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.76.7.tgz", - "integrity": "sha512-p0zWEME5qLSL1bJb93iq+zt5fz3sfVn9xFYzca1TJIpY5MommEaS64Va87lp56O0sfEIvh4307Oaf/ZzRjuLiQ==", - "optional": true, - "peer": true, + "node_modules/jest/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", "dependencies": { - "invariant": "^2.2.4", - "metro-source-map": "0.76.7", - "nullthrows": "^1.1.1", - "source-map": "^0.5.6", - "through2": "^2.0.1", - "vlq": "^1.0.0" - }, - "bin": { - "metro-symbolicate": "src/index.js" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-symbolicate/node_modules/metro-source-map": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.76.7.tgz", - "integrity": "sha512-Prhx7PeRV1LuogT0Kn5VjCuFu9fVD68eefntdWabrksmNY6mXK8pRqzvNJOhTojh6nek+RxBzZeD6MIOOyXS6w==", - "optional": true, - "peer": true, + "node_modules/jest/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.20.0", - "@babel/types": "^7.20.0", - "invariant": "^2.2.4", - "metro-symbolicate": "0.76.7", - "nullthrows": "^1.1.1", - "ob1": "0.76.7", - "source-map": "^0.5.6", - "vlq": "^1.0.0" + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/metro-symbolicate/node_modules/ob1": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.76.7.tgz", - "integrity": "sha512-BQdRtxxoUNfSoZxqeBGOyuT9nEYSn18xZHwGMb0mMVpn2NBcYbnyKY4BK2LIHRgw33CBGlUmE+KMaNvyTpLLtQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - } + "node_modules/jest/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" }, - "node_modules/metro-symbolicate/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" + "node_modules/jest/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" } }, - "node_modules/metro-transform-plugins": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.76.7.tgz", - "integrity": "sha512-iSmnjVApbdivjuzb88Orb0JHvcEt5veVyFAzxiS5h0QB+zV79w6JCSqZlHCrbNOkOKBED//LqtKbFVakxllnNg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.0", - "@babel/template": "^7.0.0", - "@babel/traverse": "^7.20.0", - "nullthrows": "^1.1.1" + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">=16" + "node": ">=6" } }, - "node_modules/metro-transform-worker": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.76.7.tgz", - "integrity": "sha512-cGvELqFMVk9XTC15CMVzrCzcO6sO1lURfcbgjuuPdzaWuD11eEyocvkTX0DPiRjsvgAmicz4XYxVzgYl3MykDw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.0", - "@babel/parser": "^7.20.0", - "@babel/types": "^7.20.0", - "babel-preset-fbjs": "^3.4.0", - "metro": "0.76.7", - "metro-babel-transformer": "0.76.7", - "metro-cache": "0.76.7", - "metro-cache-key": "0.76.7", - "metro-source-map": "0.76.7", - "metro-transform-plugins": "0.76.7", - "nullthrows": "^1.1.1" + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">=16" + "node": ">=6" } }, - "node_modules/metro-transform-worker/node_modules/metro-source-map": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.76.7.tgz", - "integrity": "sha512-Prhx7PeRV1LuogT0Kn5VjCuFu9fVD68eefntdWabrksmNY6mXK8pRqzvNJOhTojh6nek+RxBzZeD6MIOOyXS6w==", - "optional": true, - "peer": true, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.20.0", - "@babel/types": "^7.20.0", - "invariant": "^2.2.4", - "metro-symbolicate": "0.76.7", - "nullthrows": "^1.1.1", - "ob1": "0.76.7", - "source-map": "^0.5.6", - "vlq": "^1.0.0" + "universalify": "^2.0.0" }, - "engines": { - "node": ">=16" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/metro-transform-worker/node_modules/ob1": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.76.7.tgz", - "integrity": "sha512-BQdRtxxoUNfSoZxqeBGOyuT9nEYSn18xZHwGMb0mMVpn2NBcYbnyKY4BK2LIHRgw33CBGlUmE+KMaNvyTpLLtQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/metro-transform-worker/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "optional": true, - "peer": true, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 8" } }, - "node_modules/metro/node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "optional": true, - "peer": true - }, - "node_modules/metro/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" } }, - "node_modules/metro/node_modules/metro-react-native-babel-preset": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.76.7.tgz", - "integrity": "sha512-R25wq+VOSorAK3hc07NW0SmN8z9S/IR0Us0oGAsBcMZnsgkbOxu77Mduqf+f4is/wnWHc5+9bfiqdLnaMngiVw==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.20.0", - "@babel/plugin-proposal-async-generator-functions": "^7.0.0", - "@babel/plugin-proposal-class-properties": "^7.18.0", - "@babel/plugin-proposal-export-default-from": "^7.0.0", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.0", - "@babel/plugin-proposal-numeric-separator": "^7.0.0", - "@babel/plugin-proposal-object-rest-spread": "^7.20.0", - "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", - "@babel/plugin-proposal-optional-chaining": "^7.20.0", - "@babel/plugin-syntax-dynamic-import": "^7.8.0", - "@babel/plugin-syntax-export-default-from": "^7.0.0", - "@babel/plugin-syntax-flow": "^7.18.0", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.0.0", - "@babel/plugin-syntax-optional-chaining": "^7.0.0", - "@babel/plugin-transform-arrow-functions": "^7.0.0", - "@babel/plugin-transform-async-to-generator": "^7.20.0", - "@babel/plugin-transform-block-scoping": "^7.0.0", - "@babel/plugin-transform-classes": "^7.0.0", - "@babel/plugin-transform-computed-properties": "^7.0.0", - "@babel/plugin-transform-destructuring": "^7.20.0", - "@babel/plugin-transform-flow-strip-types": "^7.20.0", - "@babel/plugin-transform-function-name": "^7.0.0", - "@babel/plugin-transform-literals": "^7.0.0", - "@babel/plugin-transform-modules-commonjs": "^7.0.0", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.0.0", - "@babel/plugin-transform-parameters": "^7.0.0", - "@babel/plugin-transform-react-display-name": "^7.0.0", - "@babel/plugin-transform-react-jsx": "^7.0.0", - "@babel/plugin-transform-react-jsx-self": "^7.0.0", - "@babel/plugin-transform-react-jsx-source": "^7.0.0", - "@babel/plugin-transform-runtime": "^7.0.0", - "@babel/plugin-transform-shorthand-properties": "^7.0.0", - "@babel/plugin-transform-spread": "^7.0.0", - "@babel/plugin-transform-sticky-regex": "^7.0.0", - "@babel/plugin-transform-typescript": "^7.5.0", - "@babel/plugin-transform-unicode-regex": "^7.0.0", - "@babel/template": "^7.0.0", - "babel-plugin-transform-flow-enums": "^0.0.2", - "react-refresh": "^0.4.0" + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@babel/core": "*" + "node": ">= 0.8.0" } }, - "node_modules/metro/node_modules/metro-runtime": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.76.7.tgz", - "integrity": "sha512-MuWHubQHymUWBpZLwuKZQgA/qbb35WnDAKPo83rk7JRLIFPvzXSvFaC18voPuzJBt1V98lKQIonh6MiC9gd8Ug==", - "optional": true, - "peer": true, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "dependencies": { - "@babel/runtime": "^7.0.0", - "react-refresh": "^0.4.0" + "p-locate": "^5.0.0" }, "engines": { - "node": ">=16" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/metro/node_modules/metro-source-map": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.76.7.tgz", - "integrity": "sha512-Prhx7PeRV1LuogT0Kn5VjCuFu9fVD68eefntdWabrksmNY6mXK8pRqzvNJOhTojh6nek+RxBzZeD6MIOOyXS6w==", - "optional": true, - "peer": true, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" + }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", "dependencies": { - "@babel/traverse": "^7.20.0", - "@babel/types": "^7.20.0", - "invariant": "^2.2.4", - "metro-symbolicate": "0.76.7", - "nullthrows": "^1.1.1", - "ob1": "0.76.7", - "source-map": "^0.5.6", - "vlq": "^1.0.0" + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" }, "engines": { - "node": ">=16" + "node": ">=8.0" } }, - "node_modules/metro/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true + "node_modules/long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==" }, - "node_modules/metro/node_modules/ob1": { - "version": "0.76.7", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.76.7.tgz", - "integrity": "sha512-BQdRtxxoUNfSoZxqeBGOyuT9nEYSn18xZHwGMb0mMVpn2NBcYbnyKY4BK2LIHRgw33CBGlUmE+KMaNvyTpLLtQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" } }, - "node_modules/metro/node_modules/serialize-error": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", - "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", - "optional": true, - "peer": true, + "node_modules/luxon": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.3.tgz", + "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==", "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/metro/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" }, - "node_modules/metro/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "optional": true, - "peer": true, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", "dependencies": { - "node-gyp-build": "^4.3.0" + "semver": "^7.5.3" }, "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/metro/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "node": ">=10" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "optional": true, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "optional": true, - "peer": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" + "tmpl": "1.0.5" } }, - "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==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "engines": { - "node": ">= 0.6" + "node": ">= 0.4" } }, - "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==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, "engines": { "node": ">=6" } @@ -12776,7 +9232,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "devOptional": true, + "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -12792,117 +9248,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "optional": true, - "dependencies": { - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "optionalDependencies": { - "encoding": "^0.1.12" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "optional": true, - "peer": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -12932,148 +9277,39 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/msrcrypto": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/msrcrypto/-/msrcrypto-1.5.8.tgz", - "integrity": "sha512-ujZ0TRuozHKKm6eGbKHfXef7f+esIhEckmThVnz7RNyiOJd7a6MXj2JGBoL9cnPDW+JMG16MoTUh5X+XXjI66Q==" - }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" - }, - "node_modules/mv": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", - "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", - "optional": true, - "peer": true, - "dependencies": { - "mkdirp": "~0.5.1", - "ncp": "~2.0.0", - "rimraf": "~2.4.0" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/mv/node_modules/glob": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", - "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", - "optional": true, - "peer": true, - "dependencies": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mv/node_modules/rimraf": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", - "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==", - "optional": true, - "peer": true, - "dependencies": { - "glob": "^6.0.1" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "optional": true, - "peer": true, - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nan": { "version": "2.18.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz", - "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "optional": true, - "peer": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } + "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==", + "optional": true }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/ncp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", - "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", - "optional": true, - "peer": true, - "bin": { - "ncp": "bin/ncp" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "optional": true, - "peer": true - }, - "node_modules/nested-error-stacks": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz", - "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", - "optional": true, - "peer": true - }, - "node_modules/nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "optional": true, - "peer": true - }, "node_modules/nlp_compromise": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/nlp_compromise/-/nlp_compromise-4.12.1.tgz", @@ -13088,16 +9324,6 @@ "nlp_compromise": ">=4.12.0" } }, - "node_modules/nocache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz", - "integrity": "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/node-abi": { "version": "3.85.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", @@ -13109,154 +9335,162 @@ "node": ">=10" } }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "optional": true, - "peer": true - }, "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" - }, - "node_modules/node-dir": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz", - "integrity": "sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==", - "optional": true, - "peer": true, - "dependencies": { - "minimatch": "^3.0.2" - }, - "engines": { - "node": ">= 0.10.5" - } - }, - "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==", - "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-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", - "optional": true, - "peer": true, + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "license": "MIT", "engines": { - "node": ">= 6.13.0" + "node": "^18 || ^20 || >= 21" } }, "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "license": "MIT", "optional": true, "dependencies": { "env-paths": "^2.2.0", - "glob": "^7.1.4", + "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": ">= 10.12.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-gyp-build": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.1.tgz", "integrity": "sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==", + "optional": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, - "node_modules/node-gyp/node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/node-gyp/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", "optional": true, "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" + "minipass": "^7.1.2" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">= 18" } }, - "node_modules/node-gyp/node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "node_modules/node-gyp/node_modules/tar": { + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "license": "BlueOak-1.0.0", "optional": true, "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/node-gyp/node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "node_modules/node-gyp/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", "optional": true, "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" } }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "optional": true, - "peer": true + "dev": true }, "node_modules/node-releases": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", - "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", - "optional": true, - "peer": true + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/node-schedule": { "version": "2.1.1", @@ -13271,114 +9505,27 @@ "node": ">=6" } }, - "node_modules/node-stream-zip": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", - "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.12.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/antelle" - } - }, "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", "optional": true, "dependencies": { - "abbrev": "1" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": ">=6" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-package-arg": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-7.0.0.tgz", - "integrity": "sha512-xXxr8y5U0kl8dVkz2oK7yZjPBvqM2fwaO5l3Yg13p03v8+E3qQcD0JNhHzjL1vyGgxcKkD0cco+NLR72iuPk3g==", - "optional": true, - "peer": true, - "dependencies": { - "hosted-git-info": "^3.0.2", - "osenv": "^0.1.5", - "semver": "^5.6.0", - "validate-npm-package-name": "^3.0.0" - } - }, - "node_modules/npm-package-arg/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", - "optional": true, - "peer": true, - "dependencies": { - "path-key": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/nullthrows": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", - "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "optional": true, - "peer": true - }, - "node_modules/ob1": { - "version": "0.76.8", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.76.8.tgz", - "integrity": "sha512-dlBkJJV5M/msj9KYA9upc+nUWVwuOFFTbu28X6kZeGwcuW+JxaHSBZ70SYQnk5M+j5JbNLR6yKHmgW4M5E7X5g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -13446,29 +9593,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "optional": true, - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -13481,6 +9605,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -13491,24 +9616,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "optional": true, - "peer": true, - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13526,74 +9633,11 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-homedir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/osenv": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", - "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", - "optional": true, - "peer": true, - "dependencies": { - "os-homedir": "^1.0.0", - "os-tmpdir": "^1.0.0" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, + "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -13608,7 +9652,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "devOptional": true, + "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -13619,95 +9663,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "optional": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=6" } }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" - }, - "node_modules/parent-module": { + "node_modules/package-json-from-dist": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } + "license": "BlueOak-1.0.0" }, "node_modules/parse-duration": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-1.1.2.tgz", - "integrity": "sha512-p8EIONG8L0u7f8GFgfVlL4n8rnChTt8O5FSxgxMz2tjc9FMP199wxVKVB6IbKx11uTbKHACSvaLVIKNnoeNR/A==" - }, - "node_modules/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", - "optional": true, - "peer": true, - "dependencies": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/parse-png": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", - "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", - "optional": true, - "peer": true, - "dependencies": { - "pngjs": "^3.3.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-2.1.6.tgz", + "integrity": "sha512-1/A2Exg3NcJGcYdgV/dn4frR7vO2hOW/ohQ4KIgbT4W3raVcpYSszPWiL6I6cKufi4jQM5NbGRXLBj8AoLM4iQ==", + "license": "MIT" }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "devOptional": true, + "dev": true, "engines": { "node": ">=8" } @@ -13716,7 +9698,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -13725,199 +9707,90 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "optional": true, - "peer": true - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/pg-connection-string": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", - "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "optional": true, - "peer": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "optional": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/pjson": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/pjson/-/pjson-1.0.9.tgz", - "integrity": "sha512-4hRJH3YzkUpOlShRzhyxAmThSNnAaIlWZCAb27hd0pVUAXNUAHAO7XZbsPPvsCYwBFEScTmCCL6DGE8NyZ8BdQ==" - }, - "node_modules/pkg-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", - "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", - "optional": true, - "peer": true, - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "optional": true, - "peer": true, - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "optional": true, - "peer": true, - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "optional": true, - "peer": true, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "p-try": "^2.0.0" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=6" + "node": ">=16 || 14 >=14.18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "optional": true, - "peer": true, - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" }, - "node_modules/pkg-dir/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "optional": true, - "peer": true, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=4" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/plist": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", - "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", - "optional": true, - "peer": true, - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - }, - "engines": { - "node": ">=10.4.0" - } + "node_modules/pg-connection-string": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" }, - "node_modules/plist/node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", - "optional": true, - "peer": true, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { - "node": ">=10.0.0" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/plist/node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "optional": true, - "peer": true, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8.0" + "node": ">= 6" } }, - "node_modules/pngjs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", - "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", - "optional": true, - "peer": true, + "node_modules/pony-cause": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", + "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", + "license": "0BSD", "engines": { - "node": ">=4.0.0" + "node": ">=12.0.0" } }, "node_modules/possible-typed-array-names": { @@ -13928,35 +9801,6 @@ "node": ">= 0.4" } }, - "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "optional": true, - "peer": true, - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -13991,114 +9835,16 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pretty-format": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", - "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^26.6.2", - "ansi-regex": "^5.0.0", - "ansi-styles": "^4.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "optional": true, - "peer": true - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "optional": true, - "peer": true, - "dependencies": { - "asap": "~2.0.3" - } - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "optional": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", "optional": true, - "peer": true, - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, "engines": { - "node": ">= 6" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "optional": true, - "peer": true, - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "optional": true, - "peer": true - }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -14112,76 +9858,27 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "devOptional": true, + "dev": true, "engines": { "node": ">=6" } }, - "node_modules/pvtsutils": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", - "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", - "dependencies": { - "tslib": "^2.6.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", - "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/qrcode-terminal": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", - "integrity": "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==", - "optional": true, - "peer": true, - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, - "node_modules/queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "optional": true, - "peer": true, - "dependencies": { - "inherits": "~2.0.3" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "devOptional": true, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "opencollective", + "url": "https://opencollective.com/fast-check" } - ] - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } + ], + "license": "MIT" }, "node_modules/rc": { "version": "1.2.8", @@ -14205,189 +9902,21 @@ "node": ">=0.10.0" } }, - "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "optional": true, - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-devtools-core": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-4.28.5.tgz", - "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", - "optional": true, - "peer": true, - "dependencies": { - "shell-quote": "^1.6.1", - "ws": "^7" - } - }, - "node_modules/react-devtools-core/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/react-devtools-core/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "optional": true, - "peer": true - }, - "node_modules/react-native": { - "version": "0.72.4", - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.72.4.tgz", - "integrity": "sha512-+vrObi0wZR+NeqL09KihAAdVlQ9IdplwznJWtYrjnQ4UbCW6rkzZJebRsugwUneSOKNFaHFEo1uKU89HsgtYBg==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/create-cache-key-function": "^29.2.1", - "@react-native-community/cli": "11.3.6", - "@react-native-community/cli-platform-android": "11.3.6", - "@react-native-community/cli-platform-ios": "11.3.6", - "@react-native/assets-registry": "^0.72.0", - "@react-native/codegen": "^0.72.6", - "@react-native/gradle-plugin": "^0.72.11", - "@react-native/js-polyfills": "^0.72.1", - "@react-native/normalize-colors": "^0.72.0", - "@react-native/virtualized-lists": "^0.72.8", - "abort-controller": "^3.0.0", - "anser": "^1.4.9", - "base64-js": "^1.1.2", - "deprecated-react-native-prop-types": "4.1.0", - "event-target-shim": "^5.0.1", - "flow-enums-runtime": "^0.0.5", - "invariant": "^2.2.4", - "jest-environment-node": "^29.2.1", - "jsc-android": "^250231.0.0", - "memoize-one": "^5.0.0", - "metro-runtime": "0.76.8", - "metro-source-map": "0.76.8", - "mkdirp": "^0.5.1", - "nullthrows": "^1.1.1", - "pretty-format": "^26.5.2", - "promise": "^8.3.0", - "react-devtools-core": "^4.27.2", - "react-refresh": "^0.4.0", - "react-shallow-renderer": "^16.15.0", - "regenerator-runtime": "^0.13.2", - "scheduler": "0.24.0-canary-efb381bbf-20230505", - "stacktrace-parser": "^0.1.10", - "use-sync-external-store": "^1.0.0", - "whatwg-fetch": "^3.0.0", - "ws": "^6.2.2", - "yargs": "^17.6.2" - }, - "bin": { - "react-native": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "react": "18.2.0" - } - }, - "node_modules/react-native-securerandom": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/react-native-securerandom/-/react-native-securerandom-0.1.1.tgz", - "integrity": "sha512-CozcCx0lpBLevxiXEb86kwLRalBCHNjiGPlw3P7Fi27U6ZLdfjOCNRHD1LtBKcvPvI3TvkBXB3GOtLvqaYJLGw==", - "optional": true, - "dependencies": { - "base64-js": "*" - }, - "peerDependencies": { - "react-native": "*" - } - }, - "node_modules/react-native/node_modules/promise": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", - "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", - "optional": true, - "peer": true, - "dependencies": { - "asap": "~2.0.6" - } - }, - "node_modules/react-native/node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "optional": true, - "peer": true - }, - "node_modules/react-native/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", - "optional": true, - "peer": true, - "dependencies": { - "async-limiter": "~1.0.0" - } - }, - "node_modules/react-refresh": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.4.3.tgz", - "integrity": "sha512-Hwln1VNuGl/6bVwnd0Xdn1e84gT/8T9aYNL+HAKDArLCS7LWjwr7StE30IEYbIkx0Vi3vs+coQxe+SQDbGbbpA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" }, - "node_modules/react-shallow-renderer": { - "version": "16.15.0", - "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", - "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", - "optional": true, - "peer": true, - "dependencies": { - "object-assign": "^4.1.1", - "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0" - } + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" }, "node_modules/readable-stream": { "version": "3.6.2", @@ -14399,40 +9928,7 @@ "util-deprecate": "^1.0.1" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/readline": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", - "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==", - "optional": true, - "peer": true - }, - "node_modules/recast": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.21.5.tgz", - "integrity": "sha512-hjMmLaUXAm1hIuTqOdeYObMslq/q+Xff6QE3Y2P+uoHAg2nmVlLBps2hzh1UJDdMtDTMXOFewK6ky51JQIeECg==", - "optional": true, - "peer": true, - "dependencies": { - "ast-types": "0.15.2", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/recast/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" + "node": ">= 6" } }, "node_modules/reflect.getprototypeof": { @@ -14454,36 +9950,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "optional": true, - "peer": true - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", - "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", - "optional": true, - "peer": true, - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regenerator-transform": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", - "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/runtime": "^7.8.4" - } - }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", @@ -14501,112 +9967,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexpu-core": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", - "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/regjsgen": "^0.8.0", - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.1.0", - "regjsparser": "^0.9.1", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsparser": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", - "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", - "optional": true, - "peer": true, - "dependencies": { - "jsesc": "~0.5.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", - "optional": true, - "peer": true, - "bin": { - "jsesc": "bin/jsesc" - } - }, - "node_modules/remove-trailing-slash": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/remove-trailing-slash/-/remove-trailing-slash-0.1.1.tgz", - "integrity": "sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA==", - "optional": true, - "peer": true - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "optional": true, - "peer": true - }, - "node_modules/requireg": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz", - "integrity": "sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==", - "optional": true, - "peer": true, - "dependencies": { - "nested-error-stacks": "~2.0.1", - "rc": "~1.2.7", - "resolve": "~1.7.1" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/requireg/node_modules/resolve": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", - "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", - "optional": true, - "peer": true, - "dependencies": { - "path-parse": "^1.0.5" - } - }, "node_modules/resolve": { "version": "1.22.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.5.tgz", "integrity": "sha512-qWhv7PF1V95QPvRoUGHxOtnAlEvlXBylMZcjUR9pAumMmveFtcHJRXGIr+TkjfNJVQypqv2qcDiiars2y1PsSg==", - "optional": true, - "peer": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -14619,42 +9992,34 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, "engines": { - "node": ">=4" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "optional": true, + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", "engines": { "node": ">= 4" } @@ -14664,75 +10029,11 @@ "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", "integrity": "sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA==" }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "devOptional": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rfdc": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "devOptional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", @@ -14755,13 +10056,6 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, - "node_modules/safe-json-stringify": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", - "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", - "optional": true, - "peer": true - }, "node_modules/safe-regex-test": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", @@ -14778,49 +10072,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/sax": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", - "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==", - "optional": true, - "peer": true - }, - "node_modules/scheduler": { - "version": "0.24.0-canary-efb381bbf-20230505", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.24.0-canary-efb381bbf-20230505.tgz", - "integrity": "sha512-ABvovCDe/k9IluqSh4/ISoq8tIJnW8euVAWYt5j/bg6dRnqwQwiGO1F/V4AyK96NGF/FB04FhOUDuWj8IKfABA==", - "optional": true, - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "optional": true, - "peer": true, - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -14828,104 +10084,17 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "optional": true, - "peer": true, - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true - }, - "node_modules/send/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "optional": true, - "peer": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/sequelize": { - "version": "6.37.7", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz", - "integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==", + "version": "6.37.8", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", + "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", "funding": [ { "type": "opencollective", "url": "https://opencollective.com/sequelize" } ], + "license": "MIT", "dependencies": { "@types/debug": "^4.1.8", "@types/validator": "^13.7.17", @@ -14964,152 +10133,40 @@ "optional": true }, "pg-hstore": { - "optional": true - }, - "snowflake-sdk": { - "optional": true - }, - "sqlite3": { - "optional": true - }, - "tedious": { - "optional": true - } - } - }, - "node_modules/sequelize-pool": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", - "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "optional": true, - "peer": true, - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "optional": true, - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-static/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "optional": true, - "peer": true, - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "optional": true, - "peer": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } } }, - "node_modules/serve-static/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true - }, - "node_modules/serve-static/node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "optional": true, - "peer": true, - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", "engines": { - "node": ">= 0.8.0" + "node": ">= 10.0.0" } }, - "node_modules/serve-static/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" + "node_modules/sequelize/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "optional": true - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15140,38 +10197,11 @@ "node": ">= 0.4" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "optional": true, - "peer": true - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "optional": true, - "peer": true - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "optional": true, - "peer": true, - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -15183,21 +10213,11 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -15214,7 +10234,8 @@ "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true }, "node_modules/simple-concat": { "version": "1.0.1", @@ -15259,223 +10280,45 @@ "simple-concat": "^1.0.0" } }, - "node_modules/simple-plist": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", - "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", - "optional": true, - "peer": true, - "dependencies": { - "bplist-creator": "0.1.0", - "bplist-parser": "0.3.1", - "plist": "^3.0.5" - } - }, - "node_modules/simple-plist/node_modules/bplist-parser": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", - "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", - "optional": true, - "peer": true, - "dependencies": { - "big-integer": "1.6.x" - }, - "engines": { - "node": ">= 5.10.0" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "optional": true, - "peer": true - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/slice-ansi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", - "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.0", - "astral-regex": "^1.0.0", - "is-fullwidth-code-point": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/slice-ansi/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "optional": true, - "peer": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/slice-ansi/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "optional": true, - "peer": true - }, - "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "optional": true, - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "optional": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/sorted-array-functions": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==" }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", - "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", - "optional": true, - "peer": true, - "dependencies": { - "through": "2" - }, - "engines": { - "node": "*" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "optional": true, - "peer": true + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "node_modules/sqlite3": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", - "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", + "integrity": "sha512-X0czUUMG2tmSqJpEQa3tCuZSHKIx8PwM53vLZzKp/o6Rpy25fiVfjdbnZ988M8+O3ZWR1ih0K255VumCb3MAnQ==", "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { "bindings": "^1.5.0", - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.1", - "tar": "^6.1.11" + "node-addon-api": "^8.0.0", + "prebuild-install": "^7.1.3", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=20.17.0" }, "optionalDependencies": { - "node-gyp": "8.x" + "node-gyp": "12.x" }, "peerDependencies": { - "node-gyp": "8.x" + "node-gyp": "12.x" }, "peerDependenciesMeta": { "node-gyp": { @@ -15483,24 +10326,66 @@ } } }, - "node_modules/ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "optional": true, + "node_modules/sqlite3/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/sqlite3/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sqlite3/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", "dependencies": { - "minipass": "^3.1.1" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 8" + "node": ">= 18" + } + }, + "node_modules/sqlite3/node_modules/tar": { + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/sqlite3/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "optional": true, - "peer": true, + "dev": true, "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -15512,68 +10397,11 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/stackframe": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "optional": true, - "peer": true - }, - "node_modules/stacktrace-parser": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz", - "integrity": "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==", - "optional": true, - "peer": true, - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/stacktrace-parser/node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/stop-discord-phishing": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/stop-discord-phishing/-/stop-discord-phishing-0.3.3.tgz", - "integrity": "sha512-xl0GkusEhg4BDA20SuQiyAiaTnP+BfZVpEuAa211kAhqmmADrB9JSGeX9GNZfJNboI/CPiCSxAMaH+kSVr71Lw==", - "dependencies": { - "axios": "^0.24.0" - } - }, - "node_modules/stop-discord-phishing/node_modules/axios": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", - "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", - "dependencies": { - "follow-redirects": "^1.14.4" - } - }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -15585,21 +10413,6 @@ "node": ">= 0.4" } }, - "node_modules/str2buf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/str2buf/-/str2buf-1.3.0.tgz", - "integrity": "sha512-xIBmHIUHYZDP4HyoXGHYNVmxlXLXDrtFHYT0eV6IOdEj3VO9ccaF1Ejl9Oq8iFjITllpT8FhaXb4KsNmw+3EuA==" - }, - "node_modules/stream-buffers": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", - "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.10.0" - } - }, "node_modules/streamroller": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", @@ -15669,10 +10482,50 @@ } ] }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -15682,10 +10535,21 @@ "node": ">=8" } }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "engines": { "node": ">=8" } @@ -15740,6 +10604,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -15747,22 +10612,35 @@ "node": ">=8" } }, - "node_modules/strip-eof": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", - "optional": true, - "peer": true, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=6" } @@ -15772,6 +10650,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -15779,57 +10658,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", - "optional": true, - "peer": true - }, - "node_modules/structured-headers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", - "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", - "optional": true, - "peer": true - }, - "node_modules/sucrase": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", - "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "7.1.6", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -15837,26 +10670,10 @@ "node": ">=8" } }, - "node_modules/supports-hyperlinks": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", - "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "optional": true, - "peer": true, "engines": { "node": ">= 0.4" }, @@ -15864,20 +10681,20 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" }, "engines": { - "node": ">=10" + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" } }, "node_modules/tar-fs": { @@ -15911,308 +10728,82 @@ "node": ">=6" } }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/temp": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.4.tgz", - "integrity": "sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==", - "optional": true, - "peer": true, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", "dependencies": { - "rimraf": "~2.6.2" + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/temp-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", - "optional": true, - "peer": true, "engines": { "node": ">=8" } }, - "node_modules/temp/node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "optional": true, - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/tempy": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.7.1.tgz", - "integrity": "sha512-vXPxwOyaNVi9nyczO16mxmHGpl6ASC5/TVhRRHpqeYHvKQm58EaWNvZXxAhR0lYYnBOQFjXjhzeLsaXdjxLjRg==", - "optional": true, - "peer": true, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", "dependencies": { - "del": "^6.0.0", - "is-stream": "^2.0.0", - "temp-dir": "^2.0.0", - "type-fest": "^0.16.0", - "unique-string": "^2.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=10" + "node": ">=12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tempy/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tempy/node_modules/type-fest": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", - "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" + "peerDependencies": { + "picomatch": "^3 || ^4" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", - "optional": true, - "peer": true, - "dependencies": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" - }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/terser": { - "version": "5.19.4", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.4.tgz", - "integrity": "sha512-6p1DjHeuluwxDXcuT9VR8p64klWJKo1ILiy19s6C9+0Bh2+NWTX6nD9EPppiER4ICkHDVB1RkVpin/YW2nQn/g==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "optional": true, - "peer": true - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "devOptional": true - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "optional": true, - "peer": true, - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "optional": true, - "peer": true, - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/throat": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", - "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", - "optional": true, - "peer": true - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" - }, - "node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "optional": true, - "peer": true, - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "node_modules/through2/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "optional": true, - "peer": true - }, - "node_modules/through2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "optional": true, - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/through2/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "optional": true, - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "optional": true, - "peer": true - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "optional": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.6" - } + "dev": true }, "node_modules/toposort-class": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==" }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/traverse": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz", - "integrity": "sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==", - "optional": true, - "peer": true, - "dependencies": { - "gopd": "^1.0.1", - "typedarray.prototype.slice": "^1.0.3", - "which-typed-array": "^1.1.15" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "optional": true, - "peer": true - }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==" + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" }, "node_modules/tslib": { "version": "2.8.1", @@ -16246,19 +10837,18 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=4" } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -16319,98 +10909,34 @@ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typedarray.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.3.tgz", - "integrity": "sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==", - "optional": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-errors": "^1.3.0", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-offset": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ua-parser-js": { - "version": "1.0.36", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.36.tgz", - "integrity": "sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "optional": true, - "peer": true, - "engines": { - "node": "*" - } - }, - "node_modules/uglify-es": { - "version": "3.3.9", - "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz", - "integrity": "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ==", - "deprecated": "support for ECMAScript is superseded by `uglify-js` as of v3.13.0", - "optional": true, - "peer": true, - "dependencies": { - "commander": "~2.13.0", - "source-map": "~0.6.1" - }, - "bin": { - "uglifyjs": "bin/uglifyjs" + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" }, "engines": { - "node": ">=0.8.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/uglify-es/node_modules/commander": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.13.0.tgz", - "integrity": "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==", - "optional": true, - "peer": true - }, - "node_modules/uglify-es/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, - "peer": true, + "node_modules/umzug": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.8.3.tgz", + "integrity": "sha512-U9SRJI6LJvV0XwrqGMVPBkE26WHJklHZjtscJ2sEjUp7f+h4NH/25YGjPBernWLroVJvMnTkCAGC0bT0dd63qA==", + "license": "MIT", + "dependencies": { + "@rushstack/ts-command-line": "4.19.1", + "emittery": "^0.13.0", + "pony-cause": "^2.1.4", + "tinyglobby": "^0.2.16", + "type-fest": "^4.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/unbox-primitive": { @@ -16428,95 +10954,14 @@ } }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", "engines": { "node": ">=18.17" } }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "optional": true, - "peer": true - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", - "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "optional": true, - "peer": true, - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", - "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "optional": true, - "dependencies": { - "unique-slug": "^2.0.0" - } - }, - "node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - } - }, - "node_modules/unique-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "optional": true, - "peer": true, - "dependencies": { - "crypto-random-string": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -16525,20 +10970,49 @@ "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "node_modules/update-browserslist-db": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", - "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -16553,11 +11027,10 @@ "url": "https://github.com/sponsors/ai" } ], - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -16571,32 +11044,18 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, - "node_modules/url-join": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.0.tgz", - "integrity": "sha512-EGXjXJZhIHiQMK2pQukuFcL303nskqIRzWvPvV5O8miOfwoUb9G+a/Cld60kUyeaybEI94wvVClT10DtfeAExA==", - "optional": true, - "peer": true - }, - "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "optional": true, - "peer": true, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/utf-8-validate": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.3.tgz", - "integrity": "sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", + "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", "hasInstallScript": true, + "license": "MIT", + "optional": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -16609,39 +11068,19 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/valid-url": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", - "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==", - "optional": true, - "peer": true - }, - "node_modules/validate-npm-package-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", - "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", - "optional": true, - "peer": true, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", "dependencies": { - "builtins": "^1.0.3" + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" } }, "node_modules/validator": { @@ -16652,109 +11091,20 @@ "node": ">= 0.10" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vlq": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", - "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", - "optional": true, - "peer": true - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "optional": true, - "peer": true, + "dev": true, "dependencies": { "makeerror": "1.0.12" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/webcrypto-core": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.7.7.tgz", - "integrity": "sha512-7FjigXNsBfopEj+5DV2nhNpfic2vumtjjgPmeDKk45z+MJwXKKfhPB7118Pfzrmh4jqOMST6Ch37iPAHoImg5g==", - "dependencies": { - "@peculiar/asn1-schema": "^2.3.6", - "@peculiar/json-schema": "^1.1.12", - "asn1js": "^3.0.1", - "pvtsutils": "^1.3.2", - "tslib": "^2.4.0" - } - }, - "node_modules/webcrypto-shim": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/webcrypto-shim/-/webcrypto-shim-0.1.7.tgz", - "integrity": "sha512-JAvAQR5mRNRxZW2jKigWMjCMkjSdmP5cColRP1U/pTg69VgHXEi1orv5vVpJ55Zc5MIaPc1aaurzd9pjv2bveg==" - }, - "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==" - }, - "node_modules/whatwg-fetch": { - "version": "3.6.19", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.19.tgz", - "integrity": "sha512-d67JP4dHSbm2TrpFj8AbO8DnL1JXL5J9u0Kq2xW6d0TFDbCA3Muhdt8orXC22utleTVj7Prqt82baN6RBvnEgw==", - "optional": true, - "peer": true - }, - "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==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/whatwg-url-without-unicode": { - "version": "8.0.0-3", - "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", - "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==", - "optional": true, - "peer": true, - "dependencies": { - "buffer": "^5.4.3", - "punycode": "^2.1.1", - "webidl-conversions": "^5.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -16819,13 +11169,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "optional": true, - "peer": true - }, "node_modules/which-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", @@ -16844,15 +11187,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "optional": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/wkx": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", @@ -16861,13 +11195,6 @@ "@types/node": "*" } }, - "node_modules/wonka": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/wonka/-/wonka-4.0.15.tgz", - "integrity": "sha512-U0IUQHKXXn6PFo9nqsHphVCE5m3IntqZNB9Jjn7EB1lrR7YTDY3YWgFvEvwniTzXSvOH/XMzAZaIfJF/LvHYXg==", - "optional": true, - "peer": true - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -16877,17 +11204,23 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrappy": { @@ -16895,18 +11228,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "node_modules/write-file-atomic": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", - "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.1.11", - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.2" - } - }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -16927,80 +11248,11 @@ } } }, - "node_modules/xcode": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", - "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", - "optional": true, - "peer": true, - "dependencies": { - "simple-plist": "^1.1.0", - "uuid": "^7.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/xcode/node_modules/uuid": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", - "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", - "optional": true, - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/xml2js": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", - "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", - "optional": true, - "peer": true, - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xml2js/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/xmlbuilder": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz", - "integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=10" } @@ -17009,23 +11261,13 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "optional": true, - "peer": true - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } + "dev": true }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "optional": true, - "peer": true, + "dev": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -17043,8 +11285,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "optional": true, - "peer": true, + "dev": true, "engines": { "node": ">=12" } @@ -17053,7 +11294,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "devOptional": true, + "dev": true, "engines": { "node": ">=10" }, @@ -17061,13 +11302,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zlib-sync": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/zlib-sync/-/zlib-sync-0.1.8.tgz", - "integrity": "sha512-Xbu4odT5SbLsa1HFz8X/FvMgUbJYWxJYKB2+bqxJ6UOIIPaVGrqHEB3vyXDltSA6tTqBhSGYLgiVpzPQHYi3lA==", + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/zlib-sync/-/zlib-sync-0.1.10.tgz", + "integrity": "sha512-t7/pYg5tLBznL1RuhmbAt8rNp5tbhr+TSrJFnMkRtrGIaPJZ6Dc0uR4u3OoQI2d6cGlVI62E3Gy6gwkxyIqr/w==", "hasInstallScript": true, + "license": "MIT", + "optional": true, "dependencies": { - "nan": "^2.17.0" + "nan": "^2.18.0" } } } diff --git a/package.json b/package.json index 331ee76f..671ae755 100644 --- a/package.json +++ b/package.json @@ -10,46 +10,48 @@ "scripts": { "start": "node main.js", "test": "npx eslint ./", + "test:unit": "jest tests/", + "lint": "npx eslint ./", "verify-configs": "node scripts/verify-config-defaults.js", "generate-config": "node generate-config.js", "generate-template": "node generate-template.js" }, - "author": "SC Network Team", + "author": "ScootKit Team", "contributors": [ - "SCDerox " + "SCDerox " ], "license": "LicenseRef-LICENSE", "dependencies": { - "@androz2091/discord-invites-tracker": "1.1.1", - "@pixelfactory/privatebin": "2.6.1", "@scderox/ikea-name-generator": "1.0.0", - "@twurple/api": "5.3.4", - "@twurple/auth": "5.3.4", + "@twurple/api": "8.1.4", + "@twurple/auth": "8.1.4", "age-calculator": "1.0.0", - "bs58": "5.0.0", - "bufferutil": "4.0.7", - "centra": "2.6.0", - "discord-api-types": "0.38.37", - "discord-logs": "2.2.1", - "discord.js": "14.26.2", - "dotenv": "16.3.1", - "erlpack": "github:discord/erlpack", - "fparser": "3.1.0", - "fs-extra": "11.1.1", - "html-entities": "2.4.0", - "is-equal": "1.6.4", - "isomorphic-webcrypto": "2.3.8", - "jsonfile": "6.1.0", + "centra": "2.7.0", + "discord-api-types": "^0.38.47", + "discord.js": "14.26.4", + "fparser": "^4.2.0", + "is-equal": "^1.6.4", + "jsonfile": "6.2.1", "log4js": "6.9.1", "node-schedule": "2.1.1", - "parse-duration": "1.1.2", - "sequelize": "6.37.7", - "sqlite3": "5.1.7", - "stop-discord-phishing": "0.3.3", - "utf-8-validate": "6.0.3", - "zlib-sync": "0.1.8" + "parse-duration": "2.1.6", + "sequelize": "6.37.8", + "sqlite3": "6.0.1", + "umzug": "^3.8.3" + }, + "optionalDependencies": { + "bufferutil": "4.1.0", + "erlpack": "github:discord/erlpack", + "utf-8-validate": "6.0.6", + "zlib-sync": "0.1.10" }, "devDependencies": { - "eslint": "8.49.0" + "@stylistic/eslint-plugin": "^5.6.1", + "eslint": "10.4.0", + "globals": "^17.6.0", + "jest": "^30.4.2" + }, + "overrides": { + "uuid": "^11.1.1" } } \ No newline at end of file diff --git a/src/commands/reload.js b/src/commands/reload.js index 410330ec..6e8c7ba0 100644 --- a/src/commands/reload.js +++ b/src/commands/reload.js @@ -13,7 +13,7 @@ module.exports.run = async function (interaction) { await reloadConfig(interaction.client).catch((async reason => { if (interaction.client.logChannel) interaction.client.logChannel.send('⚠️️ ' + localize('reload', 'reload-failed')).catch(() => { }); - await interaction.editReply({content: localize('reload', 'reload-failed-message', {reason})}); + await interaction.editReply({content: localize('reload', 'reload-failed-message', {r: reason})}); process.exit(0); ; })).then(async (res) => { diff --git a/src/discordjs-fix.js b/src/discordjs-fix.js index e85a2a2c..5b35c962 100644 --- a/src/discordjs-fix.js +++ b/src/discordjs-fix.js @@ -75,6 +75,7 @@ const colorNames = { 'GREYPLE': 0x99AAB5, 'DARK_BUT_NOT_BLACK': 0x2C2F33, 'NOT_QUITE_BLACK': 0x23272A, + 'BLACK': 0x000000, 'DARK_NAVY': 0x2C3E50, 'DARK_GOLD': 0xC27C0E, 'DARK_RED': 0x992D22, @@ -94,7 +95,8 @@ const colorNames = { function resolveColor(color) { if (typeof color !== 'string') return color; const upper = color.toUpperCase(); - if (colorNames[upper]) return colorNames[upper]; + // Use `in` rather than truthiness so 0x000000 (BLACK) is not treated as a miss. + if (upper in colorNames) return colorNames[upper]; if (color.startsWith('#')) return parseInt(color.replace('#', ''), 16); return color; } diff --git a/src/events/guildAvailable.js b/src/events/guildAvailable.js new file mode 100644 index 00000000..155345b8 --- /dev/null +++ b/src/events/guildAvailable.js @@ -0,0 +1,11 @@ +const {localize} = require('../functions/localize'); + +module.exports.run = async (client, guild) => { + if (guild.id !== client.config.guildID) return; + if (client.botReadyAt) return; + client.logger.info(localize('main', 'home-guild-available', {g: guild.id})); + client.guild = guild; + client.botReadyAt = new Date(); +}; + +module.exports.ignoreBotReadyCheck = true; diff --git a/src/events/guildDelete.js b/src/events/guildDelete.js index f7788d31..7f3d095c 100644 --- a/src/events/guildDelete.js +++ b/src/events/guildDelete.js @@ -1,14 +1,49 @@ +const {localize} = require('../functions/localize'); + module.exports.run = async (client, guild) => { - if (!client.scnxSetup) return; if (guild.id !== client.config.guildID) return; - client.logger.error(`Bot was removed from the configured guild (${guild.id}).`); - await require('../functions/scnx-integration').reportIssue(client, { - type: 'CORE_FAILURE', - errorDescription: 'bot_not_on_guild', - errorData: { - inviteURL: `https://discord.com/oauth2/authorize?client_id=${client.user.id}&guild_id=${client.config.guildID}&disable_guild_select=true&permissions=8&scope=bot%20applications.commands` + client.logger.error(localize('main', 'home-guild-kicked', {g: guild.id})); + + if (client.scnxSetup) { + await require('../functions/scnx-integration').reportIssue(client, { + type: 'CORE_FAILURE', + errorDescription: 'bot_not_on_guild', + errorData: { + inviteURL: `https://discord.com/oauth2/authorize?client_id=${client.user.id}&guild_id=${client.config.guildID}&disable_guild_select=true&permissions=8&scope=bot%20applications.commands` + } + }); + } else { + client.logger.fatal(localize('main', 'not-invited', { + inv: `https://discord.com/oauth2/authorize?client_id=${client.user.id}&guild_id=${client.config.guildID}&disable_guild_select=true&permissions=8&scope=bot%20applications.commands` + })); + return process.exit(0); + } + + // Eager teardown so in-flight intervals/jobs stop immediately. reloadConfig() will + // also clear these on rejoin, but we cannot wait until then. + client.botReadyAt = null; + client.emit('configReload'); + for (const interval of client.intervals) clearInterval(interval); + client.intervals = []; + for (const job of client.jobs.filter(f => f !== null)) job.cancel(); + client.jobs = []; + client.guild = null; + + const onGuildCreate = async (newGuild) => { + if (newGuild.id !== client.config.guildID) return; + client.removeListener('guildCreate', onGuildCreate); + client.logger.info(localize('main', 'home-guild-rejoined')); + client.guild = newGuild; + try { + await require('../functions/configuration').reloadConfig(client); + } catch (e) { + client.logger.fatal(localize('main', 'config-check-failed')); + const sentryId = client.captureException ? client.captureException(e, {source: 'guild-rejoin-reload'}) : null; + client.logger.error(client.sanitizePath(`${e.stack || e}${sentryId ? ` [Sentry: ${sentryId}]` : ''}`)); + process.exit(0); } - }); + }; + client.on('guildCreate', onGuildCreate); }; module.exports.ignoreBotReadyCheck = true; diff --git a/src/events/guildUnavailable.js b/src/events/guildUnavailable.js new file mode 100644 index 00000000..8643574f --- /dev/null +++ b/src/events/guildUnavailable.js @@ -0,0 +1,17 @@ +const {localize} = require('../functions/localize'); + +module.exports.run = async (client, guild) => { + if (guild.id !== client.config.guildID) return; + if (!client.botReadyAt) return; + client.logger.warn(localize('main', 'home-guild-unavailable', {g: guild.id})); + client.botReadyAt = null; + + if (client.scnxSetup) { + await require('../functions/scnx-integration').reportIssue(client, { + type: 'CORE_ISSUE', + errorDescription: 'home_guild_unavailable' + }); + } +}; + +module.exports.ignoreBotReadyCheck = true; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index e527820f..98b50d06 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -19,6 +19,7 @@ module.exports.run = async (client, interaction) => { } if ((interaction.customId || '').startsWith('cc-') && client.scnxSetup) return require('../functions/scnx-integration').customCommandInteractionClick(interaction); if (interaction.isSelectMenu() && interaction.customId.startsWith('select-roles') && client.scnxSetup) return require('../functions/scnx-integration').handleSelectRoles(client, interaction); + if (interaction.isButton() && (interaction.customId === 'select-roles-apply' || interaction.customId === 'select-roles-cancel') && client.scnxSetup) return require('../functions/scnx-integration').handleSelectRoles(client, interaction); if (interaction.isButton() && interaction.customId.startsWith('srb-') && client.scnxSetup) return require('../functions/scnx-integration').handleRoleButton(client, interaction); if (!interaction.commandName) return; const command = client.commands.find(c => c.name.toLowerCase() === interaction.commandName.toLowerCase()); diff --git a/src/functions/configuration.js b/src/functions/configuration.js index eb47a0e4..bb989949 100644 --- a/src/functions/configuration.js +++ b/src/functions/configuration.js @@ -282,6 +282,7 @@ async function checkModuleConfig(moduleName, afterCheckEventFile = null) { module.exports.loadAllConfigs = loadAllConfigs; module.exports.loadConfigLocalization = loadConfigLocalization; module.exports.isLocalizedObject = isLocalizedObject; +module.exports.checkType = checkType; /** * Check type of one field diff --git a/src/functions/helpers.js b/src/functions/helpers.js index 47174a43..e6c16f48 100644 --- a/src/functions/helpers.js +++ b/src/functions/helpers.js @@ -25,13 +25,71 @@ const { MessageFlags } = require('discord.js'); const {localize} = require('./localize'); -const {PrivatebinClient} = require('@pixelfactory/privatebin'); -const privatebin = new PrivatebinClient('https://paste.scootkit.com'); -const isoCrypto = require('isomorphic-webcrypto'); -const {encode} = require('bs58'); const crypto = require('crypto'); +const zlib = require('zlib'); +const centra = require('centra'); const {client} = require('../../main'); +const PRIVATEBIN_BASE_URL = 'https://paste.scootkit.com'; +const PRIVATEBIN_PBKDF2_ITERATIONS = 100000; +const PRIVATEBIN_KEY_BYTES = 32; +const PRIVATEBIN_GCM_TAG_BITS = 128; +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +function base58Encode(bytes) { + if (bytes.length === 0) return ''; + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + const size = Math.ceil((bytes.length - zeros) * 138 / 100) + 1; + const b58 = new Uint8Array(size); + let length = 0; + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i]; + let j = 0; + for (let k = size - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) { + carry += 256 * b58[k]; + b58[k] = carry % 58; + carry = Math.floor(carry / 58); + } + length = j; + } + let it = size - length; + while (it < size && b58[it] === 0) it++; + return '1'.repeat(zeros) + Array.from(b58.slice(it), (b) => BASE58_ALPHABET[b]).join(''); +} + +function encryptPrivatebinPaste(text, masterKey, opts) { + const compression = opts.compression || 'zlib'; + const iv = crypto.randomBytes(16); + const salt = crypto.randomBytes(8); + const derivedKey = crypto.pbkdf2Sync(masterKey, salt, PRIVATEBIN_PBKDF2_ITERATIONS, PRIVATEBIN_KEY_BYTES, 'sha256'); + const adata = [ + [ + iv.toString('base64'), + salt.toString('base64'), + PRIVATEBIN_PBKDF2_ITERATIONS, + 256, + PRIVATEBIN_GCM_TAG_BITS, + 'aes', + 'gcm', + compression + ], + opts.textformat || 'plaintext', + opts.opendiscussion ? 1 : 0, + opts.burnafterreading ? 1 : 0 + ]; + let plaintext = Buffer.from(JSON.stringify({paste: text}), 'utf8'); + if (compression === 'zlib') plaintext = zlib.deflateRawSync(plaintext); + const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv, {authTagLength: PRIVATEBIN_GCM_TAG_BITS / 8}); + cipher.setAAD(Buffer.from(JSON.stringify(adata), 'utf8')); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const ct = Buffer.concat([encrypted, cipher.getAuthTag()]).toString('base64'); + return { + ct, + adata + }; +} + /** * Will loop asynchrony through every object in the array * @deprecated Since version v3.0.0. Will be deleted in v3.1.0. Use for(const value of array) instead. @@ -349,13 +407,19 @@ function buildV4Button(comp, args) { const label = inputReplacer(args, comp.label, true); if (label) btn.setLabel(truncate(label, 80)); + let hasEmoji = false; if (comp.emoji) { const emoji = typeof comp.emoji === 'string' ? comp.emoji.trim() : comp.emoji; - if (emoji && emoji !== '' && emoji !== 'null') btn.setEmoji(emoji); + if (emoji && emoji !== '' && emoji !== 'null') { + btn.setEmoji(emoji); + hasEmoji = true; + } } if (comp.disabled) btn.setDisabled(true); + let isLink = false; + let linkUrl = null; if (comp.scnx_action) { const action = comp.scnx_action; if (action.type === 'roleButton') { @@ -371,16 +435,23 @@ function buildV4Button(comp, args) { btn.setDisabled(true); btn.setCustomId(`disabled-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`); } else if (action.type === 'linkButton') { + isLink = true; btn.setStyle(ButtonStyle.Link); - if (comp.url) btn.setURL(inputReplacer(args, comp.url).trim()); + linkUrl = comp.url ? inputReplacer(args, comp.url).trim() : ''; } - } else if (style === 5 && comp.url) { - btn.setURL(inputReplacer(args, comp.url).trim()); + } else if (style === 5) { + isLink = true; + linkUrl = comp.url ? inputReplacer(args, comp.url).trim() : ''; } else if (comp.custom_id) { btn.setCustomId(comp.custom_id); } - if (!label && !comp.emoji) return null; + if (isLink) { + if (!linkUrl) return null; + btn.setURL(linkUrl); + } + + if (!label && !hasEmoji) return null; return btn; } @@ -409,16 +480,13 @@ function buildV4StringSelect(comp, args, counters) { const placeholder = inputReplacer(args, comp.placeholder, true); if (placeholder) select.setPlaceholder(truncate(placeholder, 150)); - if (typeof comp.min_values === 'number') select.setMinValues(comp.min_values); - if (typeof comp.max_values === 'number') select.setMaxValues(comp.max_values); - const options = []; for (const opt of comp.options) { - if (!opt.label || !opt.value) continue; - const option = { - label: truncate(inputReplacer(args, opt.label), 100), - value: String(opt.value) - }; + if (opt.value == null) continue; + const label = truncate(inputReplacer(args, opt.label, true) || '', 100); + const value = String(opt.value); + if (!label || !value) continue; + const option = {label, value}; const desc = inputReplacer(args, opt.description, true); if (desc) option.description = truncate(desc, 100); if (opt.emoji && opt.emoji !== '' && opt.emoji !== 'null') option.emoji = opt.emoji; @@ -426,6 +494,12 @@ function buildV4StringSelect(comp, args, counters) { } if (options.length === 0) return null; select.addOptions(options); + + if (typeof comp.min_values === 'number') select.setMinValues(Math.max(0, Math.min(comp.min_values, options.length))); + if (typeof comp.max_values === 'number') { + const min = typeof comp.min_values === 'number' ? Math.max(0, Math.min(comp.min_values, options.length)) : 0; + select.setMaxValues(Math.max(min || 1, Math.min(comp.max_values, options.length))); + } return select; } @@ -460,9 +534,10 @@ function buildV4Component(comp, args, counters) { let galleryItemCount = 0; for (const item of comp.items) { if (!item.media || !item.media.url) continue; + const url = inputReplacer(args, item.media.url).trim(); + if (!url) continue; try { - const galleryItem = new MediaGalleryItemBuilder() - .setURL(inputReplacer(args, item.media.url).trim()); + const galleryItem = new MediaGalleryItemBuilder().setURL(url); if (item.description) galleryItem.setDescription(truncate(inputReplacer(args, item.description), 1024)); if (item.spoiler) galleryItem.setSpoiler(true); gallery.addItems(galleryItem); @@ -476,7 +551,9 @@ function buildV4Component(comp, args, counters) { } case 13: { // File if (!comp.file || !comp.file.url) return null; - const file = new FileBuilder().setURL(inputReplacer(args, comp.file.url).trim()); + const url = inputReplacer(args, comp.file.url).trim(); + if (!url) return null; + const file = new FileBuilder().setURL(url); if (comp.spoiler) file.setSpoiler(true); return file; } @@ -521,7 +598,9 @@ function buildV4Component(comp, args, counters) { if (comp.accessory.type === 11) { // Thumbnail if (comp.accessory.media && comp.accessory.media.url) { - const thumb = new ThumbnailBuilder().setURL(inputReplacer(args, comp.accessory.media.url).trim()); + const thumbUrl = inputReplacer(args, comp.accessory.media.url).trim(); + if (!thumbUrl) return null; + const thumb = new ThumbnailBuilder().setURL(thumbUrl); if (comp.accessory.description) thumb.setDescription(truncate(inputReplacer(args, comp.accessory.description), 1024)); if (comp.accessory.spoiler) thumb.setSpoiler(true); section.setThumbnailAccessory(thumb); @@ -731,10 +810,115 @@ function formatDurationShort(ms) { module.exports.formatDurationShort = formatDurationShort; /** - * Posts (encrypted) content to SC Network Paste + * Returns today's date as YYYY-MM-DD in the bot's configured timezone. + * @returns {string} + */ +function todayInServerTZ() { + return new Intl.DateTimeFormat('en-CA', { + timeZone: client.config.timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).format(new Date()); +} + +module.exports.todayInServerTZ = todayInServerTZ; + +/** + * Formats a duration in seconds as a short, localized human string. + * Examples (en): 6125 -> "1h 42m", 125 -> "2m", 30 -> "30s", 0 -> "0m". + * @param {number} seconds + * @returns {string} + */ +function formatVoiceDuration(seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) return localize('helpers', 'voice-time-m', {i: 0}); + if (seconds >= 3600) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return localize('helpers', 'voice-time-hm', { + h, + m + }); + } + if (seconds >= 60) return localize('helpers', 'voice-time-m', {i: Math.floor(seconds / 60)}); + return localize('helpers', 'voice-time-s', {i: Math.floor(seconds)}); +} + +module.exports.formatVoiceDuration = formatVoiceDuration; + +const PASTE_MAX_ATTEMPTS = 3; +const PASTE_RETRY_BASE_MS = 1000; +const PASTE_RETRY_MAX_DELAY_MS = 60000; + +/* + * PrivateBin returns HTTP 200 with `{status: 1, message: "..."}` for application-level errors + * (flood protection, invalid options, oversized paste). axios won't throw in that case, so we + * need to inspect the body ourselves — otherwise res.url is undefined and the caller ends up + * with a "paste.scootkit.comundefined" URL. + */ +class PasteUploadError extends Error { + constructor(message, {response = null, cause = null, retryable = false, retryAfterMs = null} = {}) { + super(message); + this.name = 'PasteUploadError'; + this.response = response; + this.cause = cause; + this.retryable = retryable; + this.retryAfterMs = retryAfterMs; + } +} + +function classifyPrivatebinResponse(res) { + if (res && typeof res.url === 'string' && res.url.length > 0) return {ok: true}; + const message = (res && (res.message || res.error)) || 'PrivateBin response missing url'; + const lower = String(message).toLowerCase(); + // Permanent failures we should not retry — there's no point. + if (lower.includes('size') || lower.includes('large') || lower.includes('invalid')) { + return {ok: false, message, retryable: false}; + } + // Flood protection / temporary unavailability — retry with backoff. + const retryable = lower.includes('flood') || lower.includes('wait') || lower.includes('try again') || lower.includes('busy'); + return {ok: false, message, retryable}; +} + +function parseRetryAfterMs(headers) { + const retryAfterHeader = headers && (headers['retry-after'] || headers['Retry-After']); + if (!retryAfterHeader) return null; + const seconds = parseInt(retryAfterHeader, 10); + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return Math.min(seconds * 1000, PASTE_RETRY_MAX_DELAY_MS); +} + +function classifyHttpStatus(status, headers) { + const retryAfterMs = parseRetryAfterMs(headers); + if (!status) { + // No HTTP response: network error, DNS failure, socket reset, timeout. + return {retryable: true, retryAfterMs}; + } + const retryable = status === 408 || status === 425 || status === 429 || (status >= 500 && status < 600); + return {retryable, retryAfterMs, status}; +} + +function computePasteRetryDelayMs(attempt, retryAfterMs) { + if (retryAfterMs) return retryAfterMs; + const base = PASTE_RETRY_BASE_MS * Math.pow(2, attempt); + const jitter = Math.floor(Math.random() * 500); + return Math.min(base + jitter, PASTE_RETRY_MAX_DELAY_MS); +} + +function pasteSleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Posts (encrypted) content to SC Network Paste. Retries transient failures (flood protection, + * 5xx, network errors) with exponential backoff and honors Retry-After headers. Throws + * PasteUploadError when the paste cannot be created — callers should handle that explicitly + * rather than expecting a fallback URL. + * * @param {String} content Content to post * @param {Object} opts Configuration of upload entry * @return {Promise} URL to document + * @throws {PasteUploadError} */ async function postToSCNetworkPaste(content, opts = { expire: '1month', @@ -744,12 +928,103 @@ async function postToSCNetworkPaste(content, opts = { output: 'text', compression: 'zlib' }) { - const key = isoCrypto.getRandomValues(new Uint8Array(32)); - const res = await privatebin.sendText(content, key, opts); - return `https://paste.scootkit.com${res.url}#${encode(key)}`; + let lastError = null; + for (let attempt = 0; attempt < PASTE_MAX_ATTEMPTS; attempt++) { + const key = crypto.randomBytes(PRIVATEBIN_KEY_BYTES); + const { + ct, + adata + } = encryptPrivatebinPaste(content, key, opts); + let response; + try { + response = await centra(PRIVATEBIN_BASE_URL, 'POST') + .header('X-Requested-With', 'JSONHttpRequest') + .body({ + v: 2, + ct, + adata, + meta: {expire: opts.expire} + }, 'json') + .send(); + } catch (networkError) { + const { + retryable, + retryAfterMs + } = classifyHttpStatus(null, {}); + lastError = new PasteUploadError( + `PrivateBin network error: ${networkError.message || networkError}`, + { + cause: networkError, + retryable, + retryAfterMs + } + ); + if (!retryable || attempt === PASTE_MAX_ATTEMPTS - 1) throw lastError; + await pasteSleep(computePasteRetryDelayMs(attempt, retryAfterMs)); + continue; + } + const status = response.statusCode; + if (status < 200 || status >= 300) { + const { + retryable, + retryAfterMs + } = classifyHttpStatus(status, response.headers); + lastError = new PasteUploadError( + `PrivateBin HTTP error (${status})`, + { + cause: null, + retryable, + retryAfterMs + } + ); + if (!retryable || attempt === PASTE_MAX_ATTEMPTS - 1) throw lastError; + await pasteSleep(computePasteRetryDelayMs(attempt, retryAfterMs)); + continue; + } + let res; + try { + res = await response.json(); + } catch (parseError) { + lastError = new PasteUploadError('PrivateBin returned non-JSON response', { + cause: parseError, + retryable: false + }); + throw lastError; + } + const classification = classifyPrivatebinResponse(res); + if (classification.ok) { + return `${PRIVATEBIN_BASE_URL}${res.url}#${base58Encode(key)}`; + } + lastError = new PasteUploadError(`PrivateBin rejected paste: ${classification.message}`, { + response: res, + retryable: classification.retryable + }); + if (!classification.retryable || attempt === PASTE_MAX_ATTEMPTS - 1) throw lastError; + await pasteSleep(computePasteRetryDelayMs(attempt, null)); + } + throw lastError; } module.exports.postToSCNetworkPaste = postToSCNetworkPaste; +module.exports.PasteUploadError = PasteUploadError; + +// Internal building blocks exposed for unit tests; not part of the public bot API. +module.exports.__test = { + base58Encode, + encryptPrivatebinPaste, + classifyHttpStatus, + parseRetryAfterMs, + computePasteRetryDelayMs, + classifyPrivatebinResponse, + formatV4BuilderError, + mapButtonStyle, + getGlobalArgs, + buildV4Button, + buildV4StringSelect, + buildV4Component, + embedTypeSchemaV2, + embedTypeSchemaV4 +}; /** * Genrate a random string (cryptographically unsafe) @@ -760,9 +1035,10 @@ module.exports.postToSCNetworkPaste = postToSCNetworkPaste; module.exports.randomString = function (length, characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') { let result = ''; const charactersLength = characters.length; + if (charactersLength === 0) return result; for (let i = 0; i < length; i++) { - result = result + characters.charAt(Math.floor(Math.random() * - charactersLength)); + // crypto.randomInt -> unbiased, unpredictable character pick. + result = result + characters.charAt(crypto.randomInt(charactersLength)); } return result; }; @@ -831,18 +1107,20 @@ module.exports.pufferStringToSize = pufferStringToSize; * @param {Array} sites Array of MessageEmbeds (https://discord.js.org/#/docs/main/stable/class/MessageEmbed) * @param {Array} allowedUserIDs Array of User-IDs of users allowed to use the pagination * @param {Object} messageOrInteraction Message or [CommandInteraction](https://discord.js.org/#/docs/main/stable/class/CommandInteraction) to respond to + * @param {Boolean} ephemeral If the reply should be ephemeral (only when responding to an interaction) * @return {string} * @author Simon Csaba */ -async function sendMultipleSiteButtonMessage(channel, sites = [], allowedUserIDs = [], messageOrInteraction = null) { +async function sendMultipleSiteButtonMessage(channel, sites = [], allowedUserIDs = [], messageOrInteraction = null, ephemeral = false) { if (sites.length === 1) { - if (messageOrInteraction) return messageOrInteraction.reply({embeds: [sites[0]]}); + if (messageOrInteraction) return messageOrInteraction.reply({embeds: [sites[0]], ephemeral}); return await channel.send({embeds: [sites[0]]}); } let m; if (messageOrInteraction) m = await messageOrInteraction.reply({ components: [{type: 'ACTION_ROW', components: getButtons(1)}], embeds: [sites[0]], + ephemeral, fetchReply: true }); else m = await channel.send({components: [{type: 'ACTION_ROW', components: getButtons(1)}], embeds: [sites[0]]}); @@ -862,9 +1140,13 @@ async function sendMultipleSiteButtonMessage(channel, sites = [], allowedUserIDs }); }); c.on('end', () => { - m.edit({ + const payload = { components: [{type: 'ACTION_ROW', components: getButtons(currentSite, true)}], embeds: [sites[currentSite - 1]] + }; + if (ephemeral && messageOrInteraction) messageOrInteraction.editReply(payload).catch(() => { + }); + else m.edit(payload).catch(() => { }); }); @@ -937,7 +1219,12 @@ module.exports.checkForUpdates = checkForUpdates; * @returns {number} Random integer */ function randomIntFromInterval(min, max) { - return Math.floor(Math.random() * (max - min + 1) + min); + // Cryptographically secure, unbiased integer in [min, max] inclusive. + // crypto.randomInt does rejection sampling internally (no modulo bias) and is + // unpredictable, unlike Math.random. Tolerant of swapped args / non-integers. + const lo = Math.ceil(Math.min(min, max)); + const hi = Math.floor(Math.max(min, max)); + return hi > lo ? crypto.randomInt(lo, hi + 1) : lo; } module.exports.randomIntFromInterval = randomIntFromInterval; @@ -950,7 +1237,8 @@ module.exports.randomIntFromInterval = randomIntFromInterval; function randomElementFromArray(array) { if (array.length === 0) return null; if (array.length === 1) return array[0]; - return array[Math.floor(Math.random() * array.length)]; + // crypto.randomInt(max) -> unbiased index in [0, length-1]. + return array[crypto.randomInt(array.length)]; } module.exports.randomElementFromArray = randomElementFromArray; @@ -1035,7 +1323,13 @@ async function lockChannel(channel, allowedRoles = [], reason = localize('main', } const everyoneRole = channel.guild.roles.everyone; - await channel.permissionOverwrites.create(everyoneRole, { + + /* + * Use edit (not create) so we MERGE into any existing @everyone overwrite. + * create() replaces the overwrite wholesale, which would wipe a pre-existing + * VIEW_CHANNEL deny and leave e.g. a closed ticket visible to @everyone (#cmpwxd). + */ + await channel.permissionOverwrites.edit(everyoneRole, { SendMessages: false, SendMessagesInThreads: false }, {reason}); @@ -1138,7 +1432,7 @@ module.exports.moduleEnabled = moduleEnabled; */ module.exports.formatNumber = function (number, options = {}) { if (typeof number === 'string') number = parseFloat(number); - return new Intl.NumberFormat(client.locale.split('_')[0], options).format(number); + return new Intl.NumberFormat(client.bcp47Locale, options).format(number); }; /** @@ -1153,7 +1447,8 @@ module.exports.hashMD5 = function (string) { module.exports.shuffleArray = function (input) { const array = [...input]; for (let i = array.length - 1; i >= 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); + // Fisher-Yates with a cryptographically secure, unbiased index in [0, i]. + const j = crypto.randomInt(i + 1); [array[i], array[j]] = [array[j], array[i]]; } return array; diff --git a/src/functions/migrations/DatabaseSchemeVersionStorage.js b/src/functions/migrations/DatabaseSchemeVersionStorage.js new file mode 100644 index 00000000..dd4c9ffa --- /dev/null +++ b/src/functions/migrations/DatabaseSchemeVersionStorage.js @@ -0,0 +1,89 @@ +/** + * Umzug Storage adapter that uses the existing `system_DatabaseSchemeVersion` table. + * + * Migration files follow the naming convention `___V`, + * e.g. `levels_User__V1`. The double-underscore separates the legacy `model` value + * from the version. + * + * Two row formats are supported simultaneously so existing installations keep working: + * + * - Legacy: { model: 'levels_User', version: 'V2' } (one row per model, latest version only) + * - New: { model: 'levels_User__V2', version: 'applied' } (one row per executed migration) + * + * On read, a legacy row with version `V2` expands to all migration names from V1..V2 + * for that model, so a customer who is at V2 via the old code path is treated as having + * applied both V1 and V2 in the new framework. + * + * On write, we always insert new-format rows. Legacy rows are left untouched, so a + * downgrade or rollback to the old code path would still see the latest-known version. + */ + +const PREFIX_SUFFIX_SEPARATOR = '__'; + +function parseMigrationName(name) { + const idx = name.lastIndexOf(PREFIX_SUFFIX_SEPARATOR); + if (idx === -1) return null; + const model = name.slice(0, idx); + const version = name.slice(idx + PREFIX_SUFFIX_SEPARATOR.length); + return { + model, + version + }; +} + +function versionNumber(version) { + const match = (/^V(?\d+)$/).exec(version); + if (!match) return null; + return parseInt(match.groups.num, 10); +} + +class DatabaseSchemeVersionStorage { + constructor({getModel}) { + this.getModel = getModel; + } + + async logMigration({name}) { + await this.getModel().upsert({ + model: name, + version: 'applied' + }); + } + + async unlogMigration({name}) { + await this.getModel().destroy({where: {model: name}}); + const parsed = parseMigrationName(name); + if (parsed) { + await this.getModel().destroy({ + where: { + model: parsed.model, + version: parsed.version + } + }); + } + } + + async executed() { + const rows = await this.getModel().findAll(); + const names = new Set(); + + for (const row of rows) { + if (row.model.includes(PREFIX_SUFFIX_SEPARATOR)) { + names.add(row.model); + continue; + } + + const num = versionNumber(row.version || ''); + if (num !== null) { + for (let i = 1; i <= num; i++) names.add(`${row.model}${PREFIX_SUFFIX_SEPARATOR}V${i}`); + } else if (row.version) { + names.add(`${row.model}${PREFIX_SUFFIX_SEPARATOR}${row.version}`); + } + } + + return Array.from(names); + } +} + +module.exports = DatabaseSchemeVersionStorage; +module.exports.parseMigrationName = parseMigrationName; +module.exports.versionNumber = versionNumber; \ No newline at end of file diff --git a/src/functions/migrations/backup.js b/src/functions/migrations/backup.js new file mode 100644 index 00000000..34caa183 --- /dev/null +++ b/src/functions/migrations/backup.js @@ -0,0 +1,96 @@ +/** + * JSON snapshot helper for migrations. + * + * Before each migration's `up()` runs, the runner calls `backupTables(...)` with the + * list of tables the migration declares. Each non-empty table is dumped as a JSON + * array to `${client.dataDir}/migration-backups/____
.json`. + * + * Empty tables are skipped (no file written) to avoid noise on fresh installs. + * + * After a successful migration run the runner calls `pruneOldBackups` to retain only + * the most recent `DEFAULT_KEEP_COUNT` files. ISO timestamps sort lexicographically, + * so a plain alphabetical sort on filenames gives chronological order. + */ + +const fs = require('fs'); +const path = require('path'); + +const BACKUP_DIR_NAME = 'migration-backups'; +const DEFAULT_KEEP_COUNT = 20; + +function sanitizeForFilename(value) { + return String(value).replace(/[^A-Za-z0-9_-]/gu, '-'); +} + +function backupDir(client) { + return path.join(client.dataDir, BACKUP_DIR_NAME); +} + +async function ensureBackupDir(client) { + const dir = backupDir(client); + await fs.promises.mkdir(dir, {recursive: true}); + return dir; +} + +async function tableExists(sequelize, table) { + const queryInterface = sequelize.getQueryInterface(); + const tables = await queryInterface.showAllTables(); + return tables.some(t => t === table || (typeof t === 'object' && t.tableName === table)); +} + +async function backupTable(client, sequelize, migrationName, table) { + if (!(await tableExists(sequelize, table))) { + client.logger.debug(`[migrations:backup] table ${table} does not exist yet — nothing to back up`); + return null; + } + const [rows] = await sequelize.query(`SELECT * + FROM "${table}"`); + if (rows.length === 0) { + client.logger.debug(`[migrations:backup] skipped empty table ${table}`); + return null; + } + const dir = await ensureBackupDir(client); + const iso = new Date().toISOString().replace(/[:.]/gu, '-'); + const filename = `${iso}__${sanitizeForFilename(migrationName)}__${sanitizeForFilename(table)}.json`; + const filepath = path.join(dir, filename); + await fs.promises.writeFile(filepath, JSON.stringify(rows, null, 2), 'utf8'); + client.logger.info(`[migrations:backup] wrote ${rows.length} row(s) from ${table} → ${filename}`); + return filepath; +} + +async function backupTables(client, sequelize, migrationName, tables) { + if (!Array.isArray(tables) || tables.length === 0) return []; + if (!client.dataDir) { + client.logger.warn(`[migrations:backup] client.dataDir not set — skipping snapshot for ${migrationName}`); + return []; + } + const written = []; + for (const table of tables) { + const filepath = await backupTable(client, sequelize, migrationName, table); + if (filepath) written.push(filepath); + } + return written; +} + +async function pruneOldBackups(client, keepCount = DEFAULT_KEEP_COUNT, protectedFiles = new Set()) { + const dir = backupDir(client); + if (!fs.existsSync(dir)) return []; + const all = (await fs.promises.readdir(dir)).filter(f => f.endsWith('.json')); + if (all.length <= keepCount) return []; + const sorted = all.sort(); + const candidates = sorted.slice(0, sorted.length - keepCount); + const toDelete = candidates.filter(f => !protectedFiles.has(f)); + for (const file of toDelete) await fs.promises.unlink(path.join(dir, file)); + if (toDelete.length > 0) { + client.logger.info(`[migrations:backup] pruned ${toDelete.length} old backup(s), kept ${keepCount} most recent + ${protectedFiles.size} from this boot`); + } + return toDelete; +} + +module.exports = { + backupTables, + backupTable, + pruneOldBackups, + backupDir, + DEFAULT_KEEP_COUNT +}; \ No newline at end of file diff --git a/src/functions/migrations/runMigrations.js b/src/functions/migrations/runMigrations.js new file mode 100644 index 00000000..20be5ae9 --- /dev/null +++ b/src/functions/migrations/runMigrations.js @@ -0,0 +1,193 @@ +/** + * Discovers and runs Umzug-based migrations for each module. + * + * Each module that opts in has a `migrations/` directory next to `models/`/`events/`. + * Migration files are named `___V.js` and export `{ up, down }` + * functions in the Umzug v3 shape. The runner wires a per-module Umzug instance with + * the shared `DatabaseSchemeVersionStorage` adapter so executed migrations are tracked + * in the existing `system_DatabaseSchemeVersion` table. + * + * **Migration authoring rule:** every migration body MUST be idempotent. The runner + * always invokes `umzug.up()` for whatever Umzug considers pending, which on a + * brand-new install means running migrations against tables `db.sync()` already + * materialized with the current schema. Use `queryInterface.describeTable` to guard + * `addColumn` calls so they no-op when the column is already present. + * + * No "fresh install bypass" exists, because it cannot distinguish between + * (a) a brand-new install (table just created by db.sync with current schema), and + * (b) an existing install on pre-migration code (table exists with old schema, no + * marker row, columns missing). + * Treating (b) as "fresh" would mark the migration applied without ever adding the + * columns. Idempotent migration bodies are the correct alternative — they cost a + * cheap describeTable call on fresh installs and do the right thing on upgrades. + */ + +const fs = require('fs'); +const path = require('path'); +const {Umzug} = require('umzug'); +const DatabaseSchemeVersionStorage = require('./DatabaseSchemeVersionStorage'); +const { + backupTables, + pruneOldBackups, + DEFAULT_KEEP_COUNT +} = require('./backup'); + +const MODULES_DIR = path.join(__dirname, '..', '..', '..', 'modules'); + +function listModuleMigrationDirs() { + if (!fs.existsSync(MODULES_DIR)) return []; + const out = []; + for (const name of fs.readdirSync(MODULES_DIR)) { + const migrationsDir = path.join(MODULES_DIR, name, 'migrations'); + if (fs.existsSync(migrationsDir) && fs.statSync(migrationsDir).isDirectory()) { + out.push({ + moduleName: name, + dir: migrationsDir + }); + } + } + return out; +} + +function migrationFileNames(dir) { + return fs.readdirSync(dir) + .filter(f => f.endsWith('.js')) + .map(f => f.slice(0, -3)); +} + +function tablePrefixesFromNames(names) { + const prefixes = new Set(); + for (const name of names) { + const idx = name.lastIndexOf('__'); + if (idx !== -1) prefixes.add(name.slice(0, idx)); + } + return Array.from(prefixes); +} + +async function loadMigrationFile(filePath) { + try { + return require(filePath); + } catch (err) { + const wrapped = new Error(`Failed to load migration file ${filePath}: ${err.message}`); + wrapped.cause = err; + throw wrapped; + } +} + +function buildUmzug(client, dir, options = {}) { + const sequelize = client.models['DatabaseSchemeVersion'].sequelize; + const storage = new DatabaseSchemeVersionStorage({ + getModel: () => client.models['DatabaseSchemeVersion'] + }); + const {writtenThisBoot} = options; + return new Umzug({ + migrations: { + glob: path.join(dir, '*.js'), + resolve: ({ + name, + path: filePath, + context + }) => { + const stripped = name.replace(/\.js$/u, ''); + return { + name: stripped, + up: async () => { + const mig = await loadMigrationFile(filePath); + if (Array.isArray(mig.tables) && mig.tables.length > 0) { + try { + const written = await backupTables(client, context.sequelize, stripped, mig.tables); + if (writtenThisBoot) for (const p of written) writtenThisBoot.add(path.basename(p)); + } catch (backupErr) { + const e = new Error( + `[migrations] Cannot take pre-migration backup for ${stripped}: ${backupErr.message}. ` + + 'Free disk space (or fix permissions on the migration-backups directory) and retry.' + ); + e.cause = backupErr; + throw e; + } + } + return mig.up({ + name: stripped, + context + }); + }, + down: async () => { + const mig = await loadMigrationFile(filePath); + return mig.down({ + name: stripped, + context + }); + } + }; + } + }, + context: { + sequelize, + queryInterface: sequelize.getQueryInterface(), + client + }, + storage, + logger: { + info: (m) => client.logger.info(typeof m === 'string' ? m : JSON.stringify(m)), + warn: (m) => client.logger.warn(typeof m === 'string' ? m : JSON.stringify(m)), + error: (m) => client.logger.error(typeof m === 'string' ? m : JSON.stringify(m)), + debug: (m) => client.logger.debug(typeof m === 'string' ? m : JSON.stringify(m)) + } + }); +} + +async function runAllMigrations(client, hooks = {}) { + if (!client || !client.models || !client.models['DatabaseSchemeVersion']) { + throw new Error( + 'runAllMigrations: client.models.DatabaseSchemeVersion is not available. ' + + 'Ensure `client.models` is assigned after loadModels but before this call.' + ); + } + const { + onMigrationStart, + onMigrationEnd + } = hooks; + const moduleDirs = listModuleMigrationDirs(); + const writtenThisBoot = new Set(); + let anyMigrationRan = false; + + for (const { + moduleName, + dir + } of moduleDirs) { + const fileNames = migrationFileNames(dir); + if (fileNames.length === 0) continue; + + const umzug = buildUmzug(client, dir, {writtenThisBoot}); + const pending = await umzug.pending(); + if (pending.length === 0) { + client.logger.debug(`[migrations:${moduleName}] up to date`); + continue; + } + client.logger.info(`[migrations:${moduleName}] running ${pending.length} pending migration(s): ${pending.map(p => p.name).join(', ')}`); + if (onMigrationStart) onMigrationStart(); + try { + await umzug.up(); + } finally { + if (onMigrationEnd) onMigrationEnd(); + } + anyMigrationRan = true; + } + + if (anyMigrationRan && client.dataDir) { + try { + await pruneOldBackups(client, DEFAULT_KEEP_COUNT, writtenThisBoot); + } catch (err) { + client.logger.warn(`[migrations:backup] prune failed: ${err.message}`); + } + } +} + +module.exports = { + runAllMigrations, + listModuleMigrationDirs, + migrationFileNames, + tablePrefixesFromNames, + buildUmzug, + loadMigrationFile +}; \ No newline at end of file diff --git a/src/functions/nicknameManager.js b/src/functions/nicknameManager.js new file mode 100644 index 00000000..77a84dde --- /dev/null +++ b/src/functions/nicknameManager.js @@ -0,0 +1,426 @@ +class NicknameManager { + constructor(client) { + this.client = client; + + this.providers = new Map(); + + this.globalTransforms = new Map(); + + this.members = new Map(); + } + + stateFor(memberId) { + let s = this.members.get(memberId); + if (!s) { + s = { + contributions: new Map(), + lastRendered: null, + lastDecorations: null, + applyQueued: false, + pending: null + }; + this.members.set(memberId, s); + } + return s; + } + + set(memberId, source, contribution) { + const c = { + ...contribution, + source + }; + if (typeof c.priority !== 'number') c.priority = 0; + if (typeof c.exclusive !== 'boolean') c.exclusive = false; + const state = this.stateFor(memberId); + state.contributions.set(source, c); + state.applyQueued = true; + if (this.memberRefs?.has(memberId)) this.scheduleFlush(memberId); + } + + clear(memberId, source) { + const state = this.members.get(memberId); + if (!state) return; + state.contributions.delete(source); + state.applyQueued = true; + if (this.memberRefs?.has(memberId)) this.scheduleFlush(memberId); + } + + registerGlobalTransform(source, moduleName, opts) { + this.globalTransforms.set(source, { + moduleName, + position: opts.position, + value: opts.value, + priority: typeof opts.priority === 'number' ? opts.priority : 0 + }); + } + + unregisterGlobalTransform(source) { + this.globalTransforms.delete(source); + } + + registerProvider(source, moduleName, fn) { + this.providers.set(source, { + moduleName, + fn + }); + } + + unregisterProvider(source) { + this.providers.delete(source); + } + + clearAllForSource(source) { + for (const state of this.members.values()) { + state.contributions.delete(source); + } + } + + async pollProviders(member) { + const state = this.stateFor(member.id); + for (const [source, entry] of this.providers.entries()) { + if (!this.isModuleEnabled(entry.moduleName)) { + state.contributions.delete(source); + continue; + } + let result; + try { + result = await entry.fn(member); + } catch (e) { + this.client.logger?.warn?.(`[nicknameManager] provider ${source} threw for ${member.id}: ${e.message}`); + continue; + } + if (result === null || typeof result === 'undefined') { + state.contributions.delete(source); + continue; + } + const list = Array.isArray(result) ? result : [result]; + + for (const key of [...state.contributions.keys()]) { + if (key === source || key.startsWith(source + ':')) state.contributions.delete(key); + } + for (const c of list) { + const key = c.source ?? source; + const normalized = { + ...c, + source: key + }; + if (typeof normalized.priority !== 'number') normalized.priority = 0; + if (typeof normalized.exclusive !== 'boolean') normalized.exclusive = false; + state.contributions.set(key, normalized); + } + } + } + + isModuleEnabled(moduleName) { + if (!moduleName) return true; + const m = this.client.modules?.[moduleName]; + return !m || m.enabled !== false; + } + + deriveBaseFromNickname(member, state, currentDecorations) { + const current = member.nickname ?? member.user.displayName; + const last = state?.lastDecorations; + + const patterns = (Array.isArray(last) && last.length > 0) ? last : currentDecorations; + if (!Array.isArray(patterns) || patterns.length === 0) return current || member.user.displayName; + const residue = this.stripDecorations(current, patterns); + return residue || member.user.displayName; + } + + stripDecorations(s, decorations) { + if (!Array.isArray(decorations) || decorations.length === 0) return s; + const wraps = decorations + .filter(c => c.position === 'wrap') + .sort((a, b) => a.priority - b.priority); + for (const w of wraps) { + try { + if (typeof w.value !== 'function') continue; + const sentinel = '__NICK_BASE__'; + const wrapped = w.value(sentinel); + if (typeof wrapped !== 'string') continue; + const idx = wrapped.indexOf(sentinel); + if (idx === -1) continue; + const before = wrapped.slice(0, idx); + const after = wrapped.slice(idx + sentinel.length); + if (s.startsWith(before) && s.endsWith(after) && s.length >= before.length + after.length) { + s = s.slice(before.length, s.length - after.length); + } + } catch { + } + } + let prev; + do { + prev = s; + for (const c of decorations) { + + if (c.position === 'prefix') { + if (c.match instanceof RegExp) { + const re = new RegExp('^(?:' + c.match.source + ')', c.match.flags.replace('g', '')); + const m = s.match(re); + if (m && m[0].length > 0) s = s.slice(m[0].length); + } else if (typeof c.value === 'string' && c.value && s.startsWith(c.value)) { + s = s.slice(c.value.length); + } + } + if (c.position === 'suffix') { + if (c.match instanceof RegExp) { + const re = new RegExp('(?:' + c.match.source + ')$', c.match.flags.replace('g', '')); + const m = s.match(re); + if (m && m[0].length > 0) s = s.slice(0, s.length - m[0].length); + } else if (typeof c.value === 'string' && c.value && s.endsWith(c.value)) { + s = s.slice(0, -c.value.length); + } + } + } + } while (s !== prev); + return s; + } + + collectContributions(memberId) { + const state = this.members.get(memberId); + const perMember = state ? [...state.contributions.values()] : []; + const globals = [...this.globalTransforms.entries()] + .filter(([, g]) => this.isModuleEnabled(g.moduleName)) + .map(([source, g]) => ({ + source, + position: g.position, + value: g.value, + priority: g.priority, + exclusive: false + })); + return perMember.concat(globals); + } + + render(member) { + const all = this.collectContributions(member.id); + + function byPos(p) { + return all.filter(c => c.position === p); + } + + const bases = byPos('base').sort((a, b) => b.priority - a.priority); + const memberState = this.members.get(member.id); + const perMember = memberState ? [...memberState.contributions.values()] : []; + const decorations = perMember.filter(c => + c.position === 'prefix' || c.position === 'suffix' || c.position === 'wrap' + ); + let base = bases.length + ? bases[0].value + : this.deriveBaseFromNickname(member, memberState, decorations); + + const transforms = byPos('baseTransform').sort((a, b) => b.priority - a.priority); + for (const t of transforms) base = t.value(base, member); + + function filterExclusive(list) { + const exclusives = list.filter(c => c.exclusive).sort((a, b) => b.priority - a.priority); + const nonExclusive = list.filter(c => !c.exclusive); + const winner = exclusives[0]; + return [...(winner ? [winner] : []), ...nonExclusive]; + } + + const prefixGroup = filterExclusive(byPos('prefix')); + const prefixWinner = prefixGroup.find(c => c.exclusive); + const prefixRest = prefixGroup.filter(c => !c.exclusive).sort((a, b) => a.priority - b.priority); + const prefixes = prefixWinner ? [prefixWinner, ...prefixRest] : prefixRest; + + const suffixGroup = filterExclusive(byPos('suffix')); + const suffixWinner = suffixGroup.find(c => c.exclusive); + const suffixRest = suffixGroup.filter(c => !c.exclusive).sort((a, b) => b.priority - a.priority); + const suffixes = suffixWinner ? [suffixWinner, ...suffixRest] : suffixRest; + + const core = prefixes.map(c => c.value).join('') + base + suffixes.map(c => c.value).join(''); + + const wraps = filterExclusive(byPos('wrap')).sort((a, b) => b.priority - a.priority); + let result = core; + for (const w of wraps) result = w.value(result); + + const codePoints = [...result]; + if (codePoints.length > 32) result = codePoints.slice(0, 32).join(''); + return result; + } + + attachMember(member) { + this.stateFor(member.id); + + this.memberRefs = this.memberRefs || new Map(); + this.memberRefs.set(member.id, member); + } + + getLastRendered(memberId) { + return this.members.get(memberId)?.lastRendered ?? null; + } + + getContributions(memberId) { + const s = this.members.get(memberId); + return s ? [...s.contributions.values()] : []; + } + + requestUpdate(memberId) { + const state = this.stateFor(memberId); + state.applyQueued = true; + this.scheduleFlush(memberId); + } + + scheduleFlush(memberId) { + const state = this.stateFor(memberId); + if (state.flushPending) return; + state.flushPending = true; + setImmediate(() => { + state.flushPending = false; + this.flushMember(memberId).catch(e => { + this.client.logger?.warn?.(`[nicknameManager] flush error for ${memberId}: ${e.message}`); + }); + }); + } + + async flushMember(memberId) { + const state = this.stateFor(memberId); + if (!state.applyQueued) return; + state.applyQueued = false; + + const member = this.memberRefs?.get(memberId); + if (!member) return; + + await this.pollProviders(member); + + const hasEnabledGlobalTransform = [...this.globalTransforms.values()] + .some(g => this.isModuleEnabled(g.moduleName)); + const hasLastDecorations = Array.isArray(state.lastDecorations) && state.lastDecorations.length > 0; + if (state.contributions.size === 0 && !hasEnabledGlobalTransform && !hasLastDecorations) { + return; + } + + const rendered = this.render(member); + + const current = member.nickname ?? member.user.displayName; + if (rendered === current) { + + state.lastRendered = rendered; + state.lastDecorations = this.snapshotDecorations(state); + return; + } + + if (state.pending) { + try { + await state.pending; + } catch { + } + + const reRendered = this.render(member); + const reCurrent = member.nickname ?? member.user.displayName; + if (reRendered === reCurrent) { + state.lastRendered = reRendered; + state.lastDecorations = this.snapshotDecorations(state); + return; + } + state.pending = this.applySetNickname(member, reRendered, state); + await state.pending; + return; + } + + state.pending = this.applySetNickname(member, rendered, state); + await state.pending; + } + + async applySetNickname(member, value, state) { + try { + await member.setNickname(value, '[nicknameManager] update'); + state.lastRendered = value; + state.lastDecorations = this.snapshotDecorations(state); + } catch (e) { + this.client.logger?.warn?.(`[nicknameManager] setNickname failed for ${member.id} (target: "${value}"): ${e.message}`); + } finally { + state.pending = null; + } + } + + snapshotDecorations(state) { + return [...state.contributions.values()].filter(c => + c.position === 'prefix' || c.position === 'suffix' || c.position === 'wrap' + ); + } + + install() { + if (this.installed) return; + this.installed = true; + + this.client.on('configReload', () => this.handleConfigReload()); + this.client.on('botReady', () => { + + this.handleBotReady().catch(e => { + this.client.logger?.warn?.(`[nicknameManager] bootstrap failed: ${e.message}`); + }); + }); + this.client.on('guildMemberAdd', (member) => this.handleGuildMemberAdd(member)); + this.client.on('guildMemberUpdate', (oldM, newM) => this.handleGuildMemberUpdate(oldM, newM)); + this.client.on('guildMemberRemove', (member) => this.handleGuildMemberRemove(member)); + } + + handleGuildMemberRemove(member) { + if (member.guild?.id && member.guild.id !== this.client.guild?.id) return; + this.members.delete(member.id); + this.memberRefs?.delete(member.id); + } + + handleConfigReload() { + + for (const state of this.members.values()) { + state.contributions.clear(); + state.lastRendered = null; + state.lastDecorations = null; + state.applyQueued = false; + + } + } + + async handleBotReady() { + const guild = this.client.guild; + if (!guild) return; + + for (const member of guild.members.cache.values()) { + this.attachMember(member); + + if (typeof this.bootstrapMemberHookFn === 'function') { + try { + await this.bootstrapMemberHookFn(member); + } catch (e) { + this.client.logger?.warn?.(`[nicknameManager] bootstrap hook failed for ${member.id}: ${e.message}`); + } + } + + const state = this.stateFor(member.id); + state.applyQueued = true; + try { + await this.flushMember(member.id); + } catch (e) { + this.client.logger?.warn?.(`[nicknameManager] bootstrap flush failed for ${member.id}: ${e.message}`); + } + } + } + + setBootstrapMemberHook(fn) { + this.bootstrapMemberHookFn = fn; + } + + handleGuildMemberAdd(member) { + if (!this.client.botReadyAt) return; + if (member.guild.id !== this.client.guild?.id) return; + this.attachMember(member); + this.requestUpdate(member.id); + } + + handleGuildMemberUpdate(oldM, newM) { + if (!this.client.botReadyAt) return; + if (newM.partial || oldM.partial) return; + if (newM.guild.id !== this.client.guild?.id) return; + + this.attachMember(newM); + + const nicknamesWillHandle = this.client.modules?.['nicknames']?.enabled === true; + if (newM.nickname !== oldM.nickname && !nicknamesWillHandle) { + this.requestUpdate(newM.id); + } + } +} + +module.exports = NicknameManager; diff --git a/src/functions/parseDuration.js b/src/functions/parseDuration.js new file mode 100644 index 00000000..9980b1dc --- /dev/null +++ b/src/functions/parseDuration.js @@ -0,0 +1,35 @@ +// parse-duration v2.x is ESM-only. Loading strategy by environment: +// - Node 22.12+: require() of ESM works natively +// - Older Node: require() throws ERR_REQUIRE_ESM, fall back to dynamic import +// - jest: require() works through the test runner's module cache (mocks too) +// Callers stay synchronous (`durationParser('5m')`) after init() resolves. +// main.js MUST await init() during startup before any handler runs. + +let parseFn = null; +let initPromise = null; + +function extractFn(mod) { + return (mod && mod.default) || mod; +} + +function durationParser(input, format) { + if (!parseFn) throw new Error('parseDuration used before init(); call require("src/functions/parseDuration").init() during startup'); + return parseFn(input, format); +} + +durationParser.init = function init() { + if (parseFn) return Promise.resolve(); + if (!initPromise) { + try { + parseFn = extractFn(require('parse-duration')); + initPromise = Promise.resolve(); + } catch (requireError) { + initPromise = import('parse-duration').then((mod) => { + parseFn = extractFn(mod); + }); + } + } + return initPromise; +}; + +module.exports = durationParser; diff --git a/tests/__stubs__/localize.js b/tests/__stubs__/localize.js new file mode 100644 index 00000000..3135db2b --- /dev/null +++ b/tests/__stubs__/localize.js @@ -0,0 +1,11 @@ +// Deterministic localize stub: returns "." plus a stable +// representation of the args, so tests can assert on the formatting layer +// without depending on the actual locale files. +module.exports = { + localize: (namespace, key, args = {}) => { + const keys = Object.keys(args); + if (keys.length === 0) return `${namespace}.${key}`; + const argString = keys.sort().map((k) => `${k}=${args[k]}`).join(','); + return `${namespace}.${key}(${argString})`; + } +}; diff --git a/tests/__stubs__/main.js b/tests/__stubs__/main.js new file mode 100644 index 00000000..9630e2df --- /dev/null +++ b/tests/__stubs__/main.js @@ -0,0 +1,22 @@ +/* + * Test stub for the bot entrypoint. Mirrors enough of the shape that + * src/functions/helpers.js needs to load and execute without a live Discord + * client. Tests that need richer behavior can mutate `module.exports.client` + * directly in their setup. + */ +module.exports = { + client: { + config: { + disableEveryoneProtection: false, + timezone: 'UTC' + }, + strings: { + footer: 'test-footer', + footerImgUrl: '', + disableFooterTimestamp: false + }, + scnxSetup: false, + user: null, + guild: null + } +}; \ No newline at end of file diff --git a/tests/admin-tools/adminCommand.test.js b/tests/admin-tools/adminCommand.test.js new file mode 100644 index 00000000..a1b4d13e --- /dev/null +++ b/tests/admin-tools/adminCommand.test.js @@ -0,0 +1,80 @@ +/* + * Tests for the /admin movechannel & moverole subcommands (commands/admin.js). + * Covers the "no new-position given -> report current position" branch versus + * the "position supplied -> apply setPosition and confirm" branch, for both + * channels and roles. + */ +const admin = require('../../modules/admin-tools/commands/admin'); + +function makeInteraction({ + newPosition, + target + }) { + return { + options: { + getChannel: () => target, + getRole: () => target, + get: (n) => (n === 'new-position' && newPosition !== undefined ? {value: newPosition} : null), + getInteger: () => newPosition + }, + reply: jest.fn().mockResolvedValue() + }; +} + +describe('movechannel', () => { + test('reports the current position when no new position is given', async () => { + const channel = { + position: 4, + setPosition: jest.fn().mockResolvedValue(), + toString: () => '<#c>' + }; + const i = makeInteraction({target: channel}); + await admin.subcommands.movechannel(i); + expect(channel.setPosition).not.toHaveBeenCalled(); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('position')})); + }); + + test('applies setPosition and confirms when a position is supplied', async () => { + const channel = { + position: 4, + setPosition: jest.fn().mockResolvedValue(), + toString: () => '<#c>' + }; + const i = makeInteraction({ + newPosition: 2, + target: channel + }); + await admin.subcommands.movechannel(i); + expect(channel.setPosition).toHaveBeenCalledWith(2); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('position-changed')})); + }); +}); + +describe('moverole', () => { + test('reports the current position when no new position is given', async () => { + const role = { + position: 7, + setPosition: jest.fn().mockResolvedValue(), + toString: () => '<@&r>' + }; + const i = makeInteraction({target: role}); + await admin.subcommands.moverole(i); + expect(role.setPosition).not.toHaveBeenCalled(); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('position')})); + }); + + test('applies setPosition and confirms when a position is supplied', async () => { + const role = { + position: 7, + setPosition: jest.fn().mockResolvedValue(), + toString: () => '<@&r>' + }; + const i = makeInteraction({ + newPosition: 3, + target: role + }); + await admin.subcommands.moverole(i); + expect(role.setPosition).toHaveBeenCalledWith(3); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('position-changed')})); + }); +}); \ No newline at end of file diff --git a/tests/admin-tools/rolesBeforeSubcommand.test.js b/tests/admin-tools/rolesBeforeSubcommand.test.js new file mode 100644 index 00000000..fa710260 --- /dev/null +++ b/tests/admin-tools/rolesBeforeSubcommand.test.js @@ -0,0 +1,141 @@ +/* + * Tests for the /roles beforeSubcommand validator (commands/roles.js). + * Covers the guard chain run before any role change: + * - unknown target member -> error reply + * - target role above the bot's highest role -> refused + * - target role at/above the caller's highest role (non-owner) -> refused + * - owner bypasses the caller-hierarchy check + * - invalid / too-short duration -> refused + * - valid duration -> parsed, removeDate set, interaction deferred + * - no role option -> straight to deferReply + */ +// parse-duration is ESM-only; stub it so the wrapper resolves synchronously. +jest.mock('parse-duration', () => ({ + __esModule: true, + default: (input) => { + if (input === '1h') return 3600000; + if (input === '5s') return 5000; + return null; + } +})); + +const durationParser = require('../../src/functions/parseDuration'); +const before = require('../../modules/admin-tools/commands/roles').beforeSubcommand; + +beforeAll(() => durationParser.init()); + +function role(position, id = 'r') { + return { + position, + id, + toString: () => `<@&${id}>` + }; +} + +function makeInteraction({ + member = null, + role: targetRole = null, + duration = null, + botHighest = 10, + callerHighest = 9, + ownerId = 'owner', + userId = 'caller' + } = {}) { + return { + guild: { + ownerId, + me: {roles: {highest: role(botHighest, 'bot')}}, + members: {fetch: jest.fn().mockResolvedValue(member)} + }, + member: {roles: {highest: role(callerHighest, 'caller')}}, + user: {id: userId}, + options: { + getUser: () => ({id: 'target'}), + getRole: () => targetRole, + getString: (n) => (n === 'duration' ? duration : null) + }, + reply: jest.fn().mockResolvedValue(), + deferReply: jest.fn().mockResolvedValue() + }; +} + +test('rejects when the target member cannot be fetched', async () => { + const i = makeInteraction({member: null}); + await before(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('user-not-found')})); + expect(i.deferReply).not.toHaveBeenCalled(); +}); + +test('refuses a role positioned above the bot\'s highest role', async () => { + const i = makeInteraction({ + member: {id: 'target'}, + role: role(20), + botHighest: 10 + }); + await before(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('role-not-high-enough')})); + expect(i.deferReply).not.toHaveBeenCalled(); +}); + +test('refuses a non-owner managing a role at/above their own highest', async () => { + const i = makeInteraction({ + member: {id: 'target'}, + role: role(9), + botHighest: 30, + callerHighest: 9, + userId: 'caller' + }); + await before(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('users-trying-to-manage-higher-role')})); +}); + +test('owner bypasses the caller-hierarchy check and defers', async () => { + const i = makeInteraction({ + member: {id: 'target'}, + role: role(9), + botHighest: 30, + callerHighest: 9, + ownerId: 'owner', + userId: 'owner' + }); + await before(i); + expect(i.deferReply).toHaveBeenCalledWith({ephemeral: true}); +}); + +test('rejects a duration that is too short', async () => { + const i = makeInteraction({ + member: {id: 'target'}, + role: role(5), + botHighest: 30, + callerHighest: 20, + duration: '5s' + }); + await before(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('duration-wrong')})); + expect(i.deferReply).not.toHaveBeenCalled(); +}); + +test('accepts a valid duration, sets removeDate and defers', async () => { + const i = makeInteraction({ + member: {id: 'target'}, + role: role(5), + botHighest: 30, + callerHighest: 20, + duration: '1h' + }); + await before(i); + expect(i.duration).toBe(3600000); + expect(i.removeDate).toBeInstanceOf(Date); + expect(i.deferReply).toHaveBeenCalledWith({ephemeral: true}); + expect(i.reply).not.toHaveBeenCalled(); +}); + +test('defers directly when no role option is provided', async () => { + const i = makeInteraction({ + member: {id: 'target'}, + role: null + }); + await before(i); + expect(i.deferReply).toHaveBeenCalledWith({ephemeral: true}); + expect(i.reply).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/admin-tools/rolesSubcommands.test.js b/tests/admin-tools/rolesSubcommands.test.js new file mode 100644 index 00000000..f00bb389 --- /dev/null +++ b/tests/admin-tools/rolesSubcommands.test.js @@ -0,0 +1,173 @@ +/* + * Tests for the /roles subcommands give / remove / status + * (modules/admin-tools/commands/roles.js). + * + * beforeSubcommand (covered elsewhere) resolves the member and may set + * interaction.removeDate; the subcommands themselves: + * - bail out immediately if a previous reply already happened + * (interaction.replied — the validator failed) + * - give/remove: add/remove the role with an audit-log reason, and on success + * schedule a temporary inverse change when a removeDate is present, then + * confirm via editReply. On failure they surface the error. + * - status: lists the user's temporary role actions, or reports none. + * + * temporaryRoles.createTemporaryRoleChangeAction is mocked so no DB/timer runs. + */ + +const mockCreateChange = jest.fn(); +jest.mock('../../modules/admin-tools/temporaryRoles', () => ({ + createTemporaryRoleAction: jest.fn(), + createTemporaryRoleChangeAction: (...a) => mockCreateChange(...a) +})); + +const roles = require('../../modules/admin-tools/commands/roles'); +// status reads from the module-level `client` (require('.../main')), not +// interaction.client. Wire the stub's models per-test below. +const stubMain = require('../__stubs__/main'); + +function makeRole(id = 'r1') { + return { + id, + toString: () => `<@&${id}>` + }; +} + +function makeInteraction({ + replied = false, + removeDate = null, + role = makeRole(), + addResult = 'ok', + removeResult = 'ok', + tempActions = [] + } = {}) { + const member = { + toString: () => '<@u1>', + roles: { + add: addResult === 'ok' ? jest.fn().mockResolvedValue() : jest.fn().mockRejectedValue(new Error('boom')), + remove: removeResult === 'ok' ? jest.fn().mockResolvedValue() : jest.fn().mockRejectedValue(new Error('boom')) + } + }; + return { + replied, + removeDate, + member, + user: { + id: 'u1', + username: 'admin' + }, + client: { + bcp47Locale: 'en-US', + models: {'admin-tools': {TemporaryRoleChange: {findAll: jest.fn().mockResolvedValue(tempActions)}}} + }, + options: { + getMember: () => member, + getRole: () => role, + getUser: () => ({id: 'u1'}) + }, + editReply: jest.fn().mockResolvedValue() + }; +} + +beforeEach(() => mockCreateChange.mockClear()); + +describe('give', () => { + test('does nothing when the interaction was already replied to', async () => { + const i = makeInteraction({replied: true}); + await roles.subcommands.give(i); + expect(i.member.roles.add).not.toHaveBeenCalled(); + }); + + test('adds the role and confirms on success', async () => { + const i = makeInteraction(); + await roles.subcommands.give(i); + await new Promise(r => setImmediate(r)); + expect(i.member.roles.add).toHaveBeenCalled(); + expect(i.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('role-add')})); + expect(mockCreateChange).not.toHaveBeenCalled(); + }); + + test('schedules an inverse removal when a removeDate is set', async () => { + const removeDate = new Date(Date.now() + 60000); + const role = makeRole('r5'); + const i = makeInteraction({ + removeDate, + role + }); + await roles.subcommands.give(i); + await new Promise(r => setImmediate(r)); + expect(mockCreateChange).toHaveBeenCalledWith(expect.anything(), 'remove', removeDate, 'r5', 'u1'); + }); + + test('reports an error embed when adding the role fails', async () => { + const i = makeInteraction({addResult: 'fail'}); + await roles.subcommands.give(i); + await new Promise(r => setImmediate(r)); + expect(i.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('unable-to-change-roles')})); + }); +}); + +describe('remove', () => { + test('removes the role and schedules an inverse add when timed', async () => { + const removeDate = new Date(Date.now() + 60000); + const role = makeRole('r7'); + const i = makeInteraction({ + removeDate, + role + }); + await roles.subcommands.remove(i); + await new Promise(r => setImmediate(r)); + expect(i.member.roles.remove).toHaveBeenCalled(); + expect(mockCreateChange).toHaveBeenCalledWith(expect.anything(), 'add', removeDate, 'r7', 'u1'); + }); + + test('surfaces the failure when removing the role rejects', async () => { + const i = makeInteraction({removeResult: 'fail'}); + await roles.subcommands.remove(i); + await new Promise(r => setImmediate(r)); + expect(i.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('unable-to-change-roles')})); + }); + + test('short-circuits when already replied', async () => { + const i = makeInteraction({replied: true}); + await roles.subcommands.remove(i); + expect(i.member.roles.remove).not.toHaveBeenCalled(); + }); +}); + +describe('status', () => { + test('reports when the user has no temporary actions', async () => { + const i = makeInteraction({tempActions: []}); + stubMain.client.models = i.client.models; + await roles.subcommands.status(i); + expect(i.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('user-without-temporary-action')})); + }); + + test('lists each temporary action with its role mention', async () => { + const tempActions = [ + { + type: 'add', + roleID: 'r1', + changeDate: `${Date.now() + 1000}` + }, + { + type: 'remove', + roleID: 'r2', + changeDate: `${Date.now() + 2000}` + } + ]; + const i = makeInteraction({tempActions}); + stubMain.client.models = i.client.models; + await roles.subcommands.status(i); + const content = i.editReply.mock.calls[0][0].content; + expect(content).toContain('<@&r1>'); + expect(content).toContain('<@&r2>'); + expect(content).toContain('status-add'); + expect(content).toContain('status-remove'); + }); + + test('does nothing when already replied', async () => { + const i = makeInteraction({replied: true}); + await roles.subcommands.status(i); + expect(i.client.models['admin-tools'].TemporaryRoleChange.findAll).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/admin-tools/stealemote.test.js b/tests/admin-tools/stealemote.test.js new file mode 100644 index 00000000..7ea3245a --- /dev/null +++ b/tests/admin-tools/stealemote.test.js @@ -0,0 +1,66 @@ +/* + * Tests for the /stealemote command (modules/admin-tools/commands/stealemote.js). + * + * Parses a "<:name:id>" / "" emote string into name + cdn URL and + * creates it on the guild. Covers: + * - the validation guard that rejects strings without both a name and an id + * - the happy path: the cdn attachment URL + name + audit reason passed to + * emojis.create, and the success reply + * - animated emotes (leading 'a:') currently fail the strict 3-part parse + */ + +const emote = require('../../modules/admin-tools/commands/stealemote'); + +function makeInteraction(emoteString) { + const created = {toString: () => ':imported:'}; + return { + options: {getString: () => emoteString}, + user: { + username: 'admin', + discriminator: '0001', + globalName: null + }, + guild: {emojis: {create: jest.fn().mockResolvedValue(created)}}, + reply: jest.fn().mockResolvedValue() + }; +} + +describe('run', () => { + test('imports a standard custom emote with the right cdn URL and name', async () => { + const i = makeInteraction('<:smile:123456789>'); + await emote.run(i); + expect(i.guild.emojis.create).toHaveBeenCalledWith(expect.objectContaining({ + attachment: 'https://cdn.discordapp.com/emojis/123456789', + name: 'smile' + })); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('emoji-import')})); + }); + + test('rejects a plain string with no colons (missing name/id)', async () => { + const i = makeInteraction('justtext'); + await emote.run(i); + expect(i.guild.emojis.create).not.toHaveBeenCalled(); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('emoji-too-much-data')})); + }); + + test('rejects a string with a name but no id', async () => { + const i = makeInteraction('<:smile:>'); + await emote.run(i); + expect(i.guild.emojis.create).not.toHaveBeenCalled(); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('emoji-too-much-data')})); + }); + + test('the audit reason references the importing user', async () => { + const i = makeInteraction('<:wave:999>'); + await emote.run(i); + expect(i.guild.emojis.create.mock.calls[0][0].reason).toContain('admin'); + }); +}); + +describe('config', () => { + test('requires MANAGE_EMOJIS_AND_STICKERS and a required emote option', () => { + expect(emote.config.defaultMemberPermissions).toContain('MANAGE_EMOJIS_AND_STICKERS'); + const opt = emote.config.options.find(o => o.name === 'emote'); + expect(opt.required).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/admin-tools/temporaryRoles.test.js b/tests/admin-tools/temporaryRoles.test.js new file mode 100644 index 00000000..3525bb2c --- /dev/null +++ b/tests/admin-tools/temporaryRoles.test.js @@ -0,0 +1,54 @@ +/* + * Tests for admin-tools temporaryRoles.createTemporaryRoleChangeAction. + * Covers: persisting a new scheduled role change (with the change date stored + * as an epoch ms), and de-duplicating an existing pending change for the same + * user+role (the old record is destroyed before the new one is created). + * The schedule date is set in the future so node-schedule never fires here. + */ +const {createTemporaryRoleChangeAction} = require('../../modules/admin-tools/temporaryRoles'); + +function makeClient({duplicate = null} = {}) { + const created = { + id: 'new1', + changeDate: '0', + destroy: jest.fn() + }; + const TemporaryRoleChange = { + findOne: jest.fn().mockResolvedValue(duplicate), + create: jest.fn().mockImplementation(async (data) => Object.assign(created, data)) + }; + return { + models: {'admin-tools': {TemporaryRoleChange}}, + jobs: [], + guild: {members: {fetch: jest.fn().mockResolvedValue(null)}}, + __created: created + }; +} + +test('creates a TemporaryRoleChange storing changeDate as epoch ms', async () => { + const client = makeClient(); + const when = new Date(Date.now() + 3600000); + await createTemporaryRoleChangeAction(client, 'remove', when, 'role1', 'user1'); + expect(client.models['admin-tools'].TemporaryRoleChange.create).toHaveBeenCalledWith( + expect.objectContaining({ + userID: 'user1', + roleID: 'role1', + type: 'remove', + changeDate: when.getTime() + }) + ); + // A scheduled job for a future date is tracked on the client. + expect(client.jobs.length).toBe(1); +}); + +test('destroys an existing pending change for the same user+role before creating the new one', async () => { + const duplicate = { + id: 'old1', + destroy: jest.fn().mockResolvedValue() + }; + const client = makeClient({duplicate}); + const when = new Date(Date.now() + 3600000); + await createTemporaryRoleChangeAction(client, 'add', when, 'role1', 'user1'); + expect(duplicate.destroy).toHaveBeenCalled(); + expect(client.models['admin-tools'].TemporaryRoleChange.create).toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/afk-system/afkCommand.test.js b/tests/afk-system/afkCommand.test.js new file mode 100644 index 00000000..af6e536b --- /dev/null +++ b/tests/afk-system/afkCommand.test.js @@ -0,0 +1,90 @@ +/* + * Tests for the /afk command subcommands (commands/afk.js). + * Covers start (create session, default auto-end true, explicit auto-end false, + * already-running guard) and end (destroy session, no-session guard). + */ +const afk = require('../../modules/afk-system/commands/afk'); + +function makeInteraction({ + session = null, + options = {} + } = {}) { + const AFKUser = { + findOne: jest.fn().mockResolvedValue(session), + create: jest.fn().mockResolvedValue() + }; + return { + user: {id: 'u1'}, + member: {id: 'u1'}, + options: { + getString: (n) => (n in options ? options[n] : null), + getBoolean: (n) => (n in options ? options[n] : null) + }, + client: { + configurations: { + 'afk-system': { + config: { + sessionStartedSuccessfully: 'started', + sessionEndedSuccessfully: 'ended' + } + } + }, + models: {'afk-system': {AFKUser}}, + nicknameManager: { + attachMember: jest.fn(), + requestUpdate: jest.fn() + } + }, + reply: jest.fn().mockResolvedValue() + }; +} + +describe('start', () => { + test('creates a session with the supplied reason', async () => { + const i = makeInteraction({options: {reason: 'sleeping'}}); + await afk.subcommands.start(i); + expect(i.client.models['afk-system'].AFKUser.create).toHaveBeenCalledWith( + expect.objectContaining({ + userID: 'u1', + afkMessage: 'sleeping', + autoEnd: true + }) + ); + expect(i.client.nicknameManager.requestUpdate).toHaveBeenCalledWith('u1'); + }); + + test('defaults auto-end to true when the option is omitted', async () => { + const i = makeInteraction({options: {}}); + await afk.subcommands.start(i); + expect(i.client.models['afk-system'].AFKUser.create.mock.calls[0][0].autoEnd).toBe(true); + }); + + test('honours an explicit auto-end of false', async () => { + const i = makeInteraction({options: {'auto-end': false}}); + await afk.subcommands.start(i); + expect(i.client.models['afk-system'].AFKUser.create.mock.calls[0][0].autoEnd).toBe(false); + }); + + test('refuses to start when a session already exists', async () => { + const i = makeInteraction({session: {userID: 'u1'}}); + await afk.subcommands.start(i); + expect(i.client.models['afk-system'].AFKUser.create).not.toHaveBeenCalled(); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('already-running-session')})); + }); +}); + +describe('end', () => { + test('destroys the running session', async () => { + const session = {destroy: jest.fn().mockResolvedValue()}; + const i = makeInteraction({session}); + await afk.subcommands.end(i); + expect(session.destroy).toHaveBeenCalled(); + expect(i.client.nicknameManager.requestUpdate).toHaveBeenCalledWith('u1'); + }); + + test('reports when there is no running session', async () => { + const i = makeInteraction({session: null}); + await afk.subcommands.end(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('no-running-session')})); + }); +}); \ No newline at end of file diff --git a/tests/afk-system/messageCreate.test.js b/tests/afk-system/messageCreate.test.js new file mode 100644 index 00000000..7045bbc4 --- /dev/null +++ b/tests/afk-system/messageCreate.test.js @@ -0,0 +1,143 @@ +/* + * Behavioural tests for afk-system messageCreate.run. + * Covers: auto-ending the author's own AFK session on activity, replying with + * the configured AFK notice when mentioning an AFK user (with/without reason), + * skipping self-mentions, and the early-return guards (not ready, wrong guild, + * prefix commands, bot authors). + */ +const handler = require('../../modules/afk-system/events/messageCreate'); + +function makeAFKUser(overrides = {}) { + return Object.assign({ + afkMessage: null, + autoEnd: true, + destroy: jest.fn().mockResolvedValue() + }, overrides); +} + +function makeClient({ + authorAFK = null, + mentionAFK = {} + } = {}) { + const AFKUser = { + findOne: jest.fn().mockImplementation(async ({where}) => { + if (where.autoEnd === true) return authorAFK; + return mentionAFK[where.userID] || null; + }) + }; + return { + botReadyAt: Date.now(), + guildID: 'g1', + config: {prefix: '!'}, + configurations: { + 'afk-system': { + config: { + autoEndMessage: 'welcome back %user%', + afkUserWithReason: '%user% is afk: %reason%', + afkUserWithoutReason: '%user% is afk' + } + } + }, + models: {'afk-system': {AFKUser}}, + nicknameManager: { + attachMember: jest.fn(), + requestUpdate: jest.fn() + } + }; +} + +function makeMessage({ + content = 'hi', + mentions = [] + } = {}) { + return { + guild: {id: 'g1'}, + author: { + id: 'u1', + bot: false, + toString: () => '<@u1>' + }, + member: {id: 'u1'}, + content, + mentions: {members: {values: () => mentions.values ? mentions.values() : mentions}}, + reply: jest.fn().mockResolvedValue() + }; +} + +function mentionMember(id) { + return { + id, + toString: () => `<@${id}>` + }; +} + +test('auto-ends the author\'s AFK session and notifies them', async () => { + const authorAFK = makeAFKUser({autoEnd: true}); + const client = makeClient({authorAFK}); + const msg = makeMessage(); + await handler.run(client, msg); + expect(authorAFK.destroy).toHaveBeenCalled(); + expect(client.nicknameManager.requestUpdate).toHaveBeenCalledWith('u1'); + expect(msg.reply).toHaveBeenCalled(); +}); + +test('replies with the with-reason notice when mentioning an AFK user', async () => { + const target = mentionMember('u2'); + const client = makeClient({mentionAFK: {u2: makeAFKUser({afkMessage: 'lunch'})}}); + const msg = makeMessage({mentions: [target]}); + await handler.run(client, msg); + const arg = msg.reply.mock.calls[0][0]; + expect(JSON.stringify(arg)).toContain('lunch'); +}); + +test('replies with the no-reason notice when the AFK user has no message', async () => { + const target = mentionMember('u2'); + const client = makeClient({mentionAFK: {u2: makeAFKUser({afkMessage: null})}}); + const msg = makeMessage({mentions: [target]}); + await handler.run(client, msg); + expect(msg.reply).toHaveBeenCalledTimes(1); +}); + +test('does not reply for a mention that is not AFK', async () => { + const client = makeClient({mentionAFK: {}}); + const msg = makeMessage({mentions: [mentionMember('u2')]}); + await handler.run(client, msg); + expect(msg.reply).not.toHaveBeenCalled(); +}); + +test('skips a self-mention', async () => { + const client = makeClient({mentionAFK: {u1: makeAFKUser()}}); + const msg = makeMessage({mentions: [mentionMember('u1')]}); + await handler.run(client, msg); + expect(msg.reply).not.toHaveBeenCalled(); +}); + +describe('guards', () => { + test('ignores messages when not ready', async () => { + const client = makeClient(); + client.botReadyAt = null; + const msg = makeMessage(); + await handler.run(client, msg); + expect(client.models['afk-system'].AFKUser.findOne).not.toHaveBeenCalled(); + }); + test('ignores prefixed command messages', async () => { + const client = makeClient(); + const msg = makeMessage({content: '!ping'}); + await handler.run(client, msg); + expect(client.models['afk-system'].AFKUser.findOne).not.toHaveBeenCalled(); + }); + test('ignores messages from other guilds', async () => { + const client = makeClient(); + const msg = makeMessage(); + msg.guild.id = 'other'; + await handler.run(client, msg); + expect(client.models['afk-system'].AFKUser.findOne).not.toHaveBeenCalled(); + }); + test('ignores bot authors', async () => { + const client = makeClient(); + const msg = makeMessage(); + msg.author.bot = true; + await handler.run(client, msg); + expect(client.models['afk-system'].AFKUser.findOne).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/afk-system/onLoad.test.js b/tests/afk-system/onLoad.test.js new file mode 100644 index 00000000..7f665de5 --- /dev/null +++ b/tests/afk-system/onLoad.test.js @@ -0,0 +1,60 @@ +/* + * Tests for the afk-system nickname provider (modules/afk-system/onLoad.js). + * + * onLoad registers a provider (once) that wraps a member's nickname in "[AFK]" + * when they have an active AFK session. Covers: + * - idempotent registration + * - an active session yields a wrap descriptor whose value prefixes "[AFK] " + * - no session / missing model yields null + */ + +const onLoad = require('../../modules/afk-system/onLoad').onLoad; + +function makeClient({ + session, + model = 'present' + } = {}) { + const providers = {}; + return { + _providers: providers, + nicknameManager: { + registerProvider: jest.fn((source, mod, fn) => { + providers[source] = fn; + }) + }, + models: model === 'present' + ? {'afk-system': {AFKUser: {findOne: jest.fn().mockResolvedValue(session)}}} + : {} + }; +} + +test('registers the afk provider only once', () => { + const client = makeClient({session: null}); + onLoad(client); + onLoad(client); + expect(client.nicknameManager.registerProvider).toHaveBeenCalledTimes(1); +}); + +test('wraps the nickname with [AFK] when a session exists', async () => { + const client = makeClient({session: {userID: 'm1'}}); + onLoad(client); + const result = await client._providers['afk']({id: 'm1'}); + expect(result).toMatchObject({ + source: 'afk', + position: 'wrap', + priority: 500 + }); + expect(result.value('Bob')).toBe('[AFK] Bob'); +}); + +test('returns null when the member has no session', async () => { + const client = makeClient({session: null}); + onLoad(client); + expect(await client._providers['afk']({id: 'm1'})).toBeNull(); +}); + +test('returns null when the AFKUser model is unavailable', async () => { + const client = makeClient({model: 'missing'}); + onLoad(client); + expect(await client._providers['afk']({id: 'm1'})).toBeNull(); +}); \ No newline at end of file diff --git a/tests/anti-ghostping/awaitBotMessages.test.js b/tests/anti-ghostping/awaitBotMessages.test.js new file mode 100644 index 00000000..1d8c3d11 --- /dev/null +++ b/tests/anti-ghostping/awaitBotMessages.test.js @@ -0,0 +1,134 @@ +/* + * Tests for the anti-ghostping awaitBotMessages delayed path in + * modules/anti-ghostping/events/messageDelete.js. + * + * When awaitBotMessages is on, the handler waits 2s and only fires the ghost-ping + * notice if no bot message has appeared in the channel after the deleted message + * (this suppresses notices for messages a bot deleted, e.g. automod). Covers: + * - fires after the delay when no bot message followed + * - stays silent when a bot message followed the deleted one + * - stays silent if the tracked entry was evicted before the timer fires + * + * Fake timers drive the 2s window deterministically. + */ + +const createHandler = require('../../modules/anti-ghostping/events/messageCreate.js'); +const deleteHandler = require('../../modules/anti-ghostping/events/messageDelete.js'); + +const {messageWithMentions} = createHandler; + +function clearTracked() { + for (const k of Object.keys(messageWithMentions)) delete messageWithMentions[k]; +} + +function mentionCollection(members) { + return { + filter(fn) { + const kept = members.filter(fn); + return { + size: kept.length, + forEach: (cb) => kept.forEach(cb) + }; + } + }; +} + +function makeClient() { + return { + botReadyAt: Date.now(), + guildID: 'g1', + config: {guildID: 'g1'}, + configurations: { + 'anti-ghostping': { + config: { + ignoredChannels: [], + awaitBotMessages: true, + youJustGotGhostPinged: 'ping %mentions% %authorMention%' + } + } + } + }; +} + +function makeDeletedMsg({ + id = 'm1', + followingMessages = [] + } = {}) { + const send = jest.fn().mockResolvedValue(); + return { + _send: send, + id, + guild: {id: 'g1'}, + author: { + id: 'author', + bot: false, + toString: () => '<@author>' + }, + channel: { + id: 'c1', + send, + messages: {fetch: jest.fn().mockResolvedValue(mentionCollection(followingMessages))} + }, + content: 'hey @other', + mentions: { + members: mentionCollection([{ + id: 'other', + user: {bot: false} + }]) + } + }; +} + +beforeEach(() => { + clearTracked(); + jest.useFakeTimers(); +}); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +test('fires the notice after 2s when no bot message followed', async () => { + const tracked = makeDeletedMsg(); + messageWithMentions['m1'] = tracked; + const del = makeDeletedMsg({followingMessages: []}); + + await deleteHandler.run(makeClient(), del); + expect(del._send).not.toHaveBeenCalled(); // not yet — waiting + + jest.advanceTimersByTime(2000); + await Promise.resolve(); + await Promise.resolve(); + + expect(del.channel.messages.fetch).toHaveBeenCalledWith({after: 'm1'}); + expect(del._send).toHaveBeenCalledTimes(1); +}); + +test('stays silent when a bot message followed the deleted one', async () => { + const tracked = makeDeletedMsg(); + messageWithMentions['m1'] = tracked; + const del = makeDeletedMsg({followingMessages: [{author: {bot: true}}]}); + + await deleteHandler.run(makeClient(), del); + jest.advanceTimersByTime(2000); + await Promise.resolve(); + await Promise.resolve(); + + expect(del._send).not.toHaveBeenCalled(); +}); + +test('stays silent if the tracked entry was evicted before the timer fires', async () => { + const tracked = makeDeletedMsg(); + messageWithMentions['m1'] = tracked; + const del = makeDeletedMsg({followingMessages: []}); + + await deleteHandler.run(makeClient(), del); + // Simulate the 60s eviction happening before the 2s recheck completes. + delete messageWithMentions['m1']; + + jest.advanceTimersByTime(2000); + await Promise.resolve(); + await Promise.resolve(); + + expect(del._send).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/anti-ghostping/ghostping.test.js b/tests/anti-ghostping/ghostping.test.js new file mode 100644 index 00000000..63e66786 --- /dev/null +++ b/tests/anti-ghostping/ghostping.test.js @@ -0,0 +1,214 @@ +/* + * Tests for the anti-ghostping module + * (modules/anti-ghostping/events/messageCreate.js + messageDelete.js). + * + * messageCreate records messages that ping non-bot, non-self members into an + * in-memory map (used later by messageDelete). Covers: + * - only messages with a qualifying mention are recorded + * - guild / ignored-channel / not-ready guards + * - the 60s eviction timer + * messageDelete fires a ghost-ping notice. Covers: + * - notice is sent (immediately when awaitBotMessages is off) with the + * mention/content/author substitutions + * - bot-authored deleted messages are ignored + * - untracked messages are ignored + */ + +const createHandler = require('../../modules/anti-ghostping/events/messageCreate.js'); +const deleteHandler = require('../../modules/anti-ghostping/events/messageDelete.js'); + +const {messageWithMentions} = createHandler; + +function clearTracked() { + for (const k of Object.keys(messageWithMentions)) delete messageWithMentions[k]; +} + +function makeClient({ + ignoredChannels = [], + awaitBotMessages = false + } = {}) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + config: {guildID: 'g1'}, + configurations: { + 'anti-ghostping': { + config: { + ignoredChannels, + awaitBotMessages, + youJustGotGhostPinged: 'ping %mentions% %msgContent% %authorMention%' + } + } + } + }; +} + +// mentions.members must be a discord.js Collection-like with .filter().size and .forEach. +function mentionCollection(members) { + return { + filter(fn) { + const kept = members.filter(fn); + return { + size: kept.length, + forEach: (cb) => kept.forEach(cb) + }; + } + }; +} + +function makeCreateMsg({ + id = 'm1', + channelId = 'c1', + authorId = 'author', + mentionedMembers = [] + } = {}) { + return { + id, + guild: {id: 'g1'}, + author: {id: authorId}, + channel: {id: channelId}, + content: 'hey', + mentions: {members: mentionCollection(mentionedMembers)} + }; +} + +beforeEach(() => { + clearTracked(); + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +describe('messageCreate tracking', () => { + test('records a message that pings another (non-bot) member', async () => { + const msg = makeCreateMsg({ + mentionedMembers: [{ + id: 'other', + user: {bot: false} + }] + }); + await createHandler.run(makeClient(), msg); + expect(messageWithMentions['m1']).toBe(msg); + }); + + test('does not record a self-ping', async () => { + const msg = makeCreateMsg({ + authorId: 'author', + mentionedMembers: [{ + id: 'author', + user: {bot: false} + }] + }); + await createHandler.run(makeClient(), msg); + expect(messageWithMentions['m1']).toBeUndefined(); + }); + + test('does not record a ping that only targets a bot', async () => { + const msg = makeCreateMsg({ + mentionedMembers: [{ + id: 'botMember', + user: {bot: true} + }] + }); + await createHandler.run(makeClient(), msg); + expect(messageWithMentions['m1']).toBeUndefined(); + }); + + test('ignores ignored channels', async () => { + const msg = makeCreateMsg({ + channelId: 'ignored', + mentionedMembers: [{ + id: 'other', + user: {bot: false} + }] + }); + await createHandler.run(makeClient({ignoredChannels: ['ignored']}), msg); + expect(messageWithMentions['m1']).toBeUndefined(); + }); + + test('ignores messages from other guilds', async () => { + const msg = makeCreateMsg({ + mentionedMembers: [{ + id: 'other', + user: {bot: false} + }] + }); + msg.guild.id = 'elsewhere'; + await createHandler.run(makeClient(), msg); + expect(messageWithMentions['m1']).toBeUndefined(); + }); + + test('evicts the tracked message after 60 seconds', async () => { + const msg = makeCreateMsg({ + mentionedMembers: [{ + id: 'other', + user: {bot: false} + }] + }); + await createHandler.run(makeClient(), msg); + expect(messageWithMentions['m1']).toBe(msg); + jest.advanceTimersByTime(60000); + expect(messageWithMentions['m1']).toBeUndefined(); + }); +}); + +describe('messageDelete ghost-ping notice', () => { + function makeDeletedMsg({ + id = 'm1', + authorBot = false + } = {}) { + const send = jest.fn().mockResolvedValue(); + return { + _send: send, + id, + guild: {id: 'g1'}, + author: { + id: 'author', + bot: authorBot, + toString: () => '<@author>' + }, + channel: { + id: 'c1', + send, + messages: {fetch: jest.fn().mockResolvedValue(mentionCollection([]))} + }, + content: 'hey @other', + mentions: { + members: mentionCollection([{ + id: 'other', + user: {bot: false} + }]) + } + }; + } + + test('sends a ghost-ping notice immediately when awaitBotMessages is off', async () => { + const tracked = makeDeletedMsg(); + messageWithMentions['m1'] = tracked; + const del = makeDeletedMsg(); + await deleteHandler.run(makeClient({awaitBotMessages: false}), del); + expect(del._send).toHaveBeenCalledTimes(1); + const sent = del._send.mock.calls[0][0]; + // embedType-rendered string should contain the substituted mention + author. + const text = JSON.stringify(sent); + expect(text).toContain('<@other>'); + expect(text).toContain('<@author>'); + }); + + test('ignores deleted messages authored by a bot', async () => { + const tracked = makeDeletedMsg({authorBot: true}); + messageWithMentions['m1'] = tracked; + const del = makeDeletedMsg({authorBot: true}); + await deleteHandler.run(makeClient(), del); + expect(del._send).not.toHaveBeenCalled(); + }); + + test('ignores messages that were never tracked', async () => { + const del = makeDeletedMsg({id: 'untracked'}); + await deleteHandler.run(makeClient(), del); + expect(del._send).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/auto-delete/autoDelete.test.js b/tests/auto-delete/autoDelete.test.js new file mode 100644 index 00000000..2c651bd3 --- /dev/null +++ b/tests/auto-delete/autoDelete.test.js @@ -0,0 +1,260 @@ +/* + * Tests for the auto-delete module. + * + * findUniqueChannels (botReady.js): last-writer-wins de-duplication of channel + * config entries keyed by channelID. + * + * messageCreate.js: schedules a deletion after channel.timeout minutes. Covers: + * - guard clauses (not ready / no guild / wrong guild / no member / channel + * not in the unique list) + * - keepMessageCount === 0 deletes the new message itself after the timeout + * - pinned / non-deletable messages are left alone + * - keepMessageCount > 0 deletes the oldest message once enough exist + * + * voiceStateUpdate.js: bulk-deletes messages in an empty configured voice + * channel after the configured timeout, skipping occupied channels. + */ + +const {ChannelType} = require('discord.js'); +const {findUniqueChannels} = require('../../modules/auto-delete/events/botReady.js'); +const messageCreate = require('../../modules/auto-delete/events/messageCreate.js'); +const voiceStateUpdate = require('../../modules/auto-delete/events/voiceStateUpdate.js'); + +describe('findUniqueChannels', () => { + test('keeps a single entry per channelID (last writer wins)', () => { + const input = [ + { + channelID: 'a', + timeout: '1' + }, + { + channelID: 'b', + timeout: '2' + }, + { + channelID: 'a', + timeout: '99' + } + ]; + const result = findUniqueChannels(input); + expect(result).toHaveLength(2); + const a = result.find(c => c.channelID === 'a'); + expect(a.timeout).toBe('99'); + }); + + test('returns entries unchanged when all channelIDs are unique', () => { + const input = [{channelID: 'x'}, {channelID: 'y'}]; + expect(findUniqueChannels(input)).toHaveLength(2); + }); + + test('handles an empty list', () => { + expect(findUniqueChannels([])).toEqual([]); + }); +}); + +describe('auto-delete messageCreate', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + function makeClient(uniqueChannels) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + modules: {'auto-delete': {uniqueChannels}} + }; + } + + function makeMsg({ + channelID = 'c1', + deletable = true, + pinned = false + } = {}) { + return { + id: '100', + guild: {id: 'g1'}, + member: {id: 'm1'}, + deletable, + pinned, + delete: jest.fn().mockResolvedValue(), + channel: { + id: channelID, + messages: {fetch: jest.fn().mockResolvedValue([])} + } + }; + } + + test('does nothing when the bot is not ready', async () => { + const client = makeClient([{ + channelID: 'c1', + timeout: '1', + keepMessageCount: '0' + }]); + client.botReadyAt = null; + const msg = makeMsg(); + await messageCreate.run(client, msg); + jest.runAllTimers(); + expect(msg.delete).not.toHaveBeenCalled(); + }); + + test('does nothing for a channel that is not configured', async () => { + const client = makeClient([{ + channelID: 'other', + timeout: '1', + keepMessageCount: '0' + }]); + const msg = makeMsg({channelID: 'c1'}); + await messageCreate.run(client, msg); + jest.runAllTimers(); + expect(msg.delete).not.toHaveBeenCalled(); + }); + + test('keepMessageCount=0 deletes the message itself after timeout minutes', async () => { + const client = makeClient([{ + channelID: 'c1', + timeout: '2', + keepMessageCount: '0' + }]); + const msg = makeMsg(); + await messageCreate.run(client, msg); + // not yet — timer is 2 minutes + expect(msg.delete).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(2 * 60000); + expect(msg.delete).toHaveBeenCalledTimes(1); + }); + + test('does not delete a pinned message', async () => { + const client = makeClient([{ + channelID: 'c1', + timeout: '1', + keepMessageCount: '0' + }]); + const msg = makeMsg({pinned: true}); + await messageCreate.run(client, msg); + await jest.advanceTimersByTimeAsync(60000); + expect(msg.delete).not.toHaveBeenCalled(); + }); + + test('keepMessageCount>0 deletes the oldest message once enough history exists', async () => { + const oldest = { + createdAt: new Date(1000), + deletable: true, + pinned: false, + delete: jest.fn().mockResolvedValue() + }; + const newer = { + createdAt: new Date(2000), + deletable: true, + pinned: false, + delete: jest.fn().mockResolvedValue() + }; + // collection-like: needs .sort returning array with .last() and .length + const collection = [newer, oldest]; + collection.sort = function (cmp) { + const arr = [newer, oldest].sort(cmp); + arr.last = () => arr[arr.length - 1]; + return arr; + }; + const client = makeClient([{ + channelID: 'c1', + timeout: '1', + keepMessageCount: '2' + }]); + const msg = makeMsg(); + msg.channel.messages.fetch = jest.fn().mockResolvedValue(collection); + + await messageCreate.run(client, msg); + await jest.advanceTimersByTimeAsync(60000); + + // fetch asked for messages before this one, limited to keepMessageCount + expect(msg.channel.messages.fetch).toHaveBeenCalledWith({ + before: '100', + limit: 2 + }); + // oldest (sorted last, descending) is the one removed + expect(oldest.delete).toHaveBeenCalledTimes(1); + expect(newer.delete).not.toHaveBeenCalled(); + }); +}); + +describe('auto-delete voiceStateUpdate', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + function makeClient({ + voiceChannels, + channel + }) { + return { + botReadyAt: Date.now(), + configurations: {'auto-delete': {'voice-channels': voiceChannels}}, + channels: {fetch: jest.fn().mockResolvedValue(channel)}, + logger: {error: jest.fn()} + }; + } + + test('ignores voice channels not in the config', async () => { + const client = makeClient({ + voiceChannels: [{ + channelID: 'vc-x', + timeout: '1' + }], + channel: null + }); + await voiceStateUpdate.run(client, {channelId: 'vc-other'}); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + test('skips a voice channel that still has members', async () => { + const bulkDelete = jest.fn().mockResolvedValue(); + const channel = { + type: ChannelType.GuildVoice, + members: {size: 2}, + messages: {fetch: jest.fn()}, + bulkDelete + }; + const client = makeClient({ + voiceChannels: [{ + channelID: 'vc1', + timeout: '1' + }], + channel + }); + await voiceStateUpdate.run(client, {channelId: 'vc1'}); + jest.runAllTimers(); + expect(bulkDelete).not.toHaveBeenCalled(); + }); + + test('bulk-deletes messages of an empty voice channel after the timeout', async () => { + const messages = {size: 3}; + const bulkDelete = jest.fn().mockResolvedValue(); + const channel = { + type: ChannelType.GuildVoice, + members: {size: 0}, + messages: {fetch: jest.fn().mockResolvedValue(messages)}, + bulkDelete + }; + const client = makeClient({ + voiceChannels: [{ + channelID: 'vc1', + timeout: '3' + }], + channel + }); + await voiceStateUpdate.run(client, {channelId: 'vc1'}); + expect(bulkDelete).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(3 * 60 * 1000); + expect(bulkDelete).toHaveBeenCalledWith(messages, true); + }); + + test('logs an error and aborts when the channel cannot be fetched', async () => { + const client = makeClient({ + voiceChannels: [{ + channelID: 'vc1', + timeout: '1' + }], + channel: undefined + }); + await voiceStateUpdate.run(client, {channelId: 'vc1'}); + expect(client.logger.error).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/auto-delete/botReadyRun.test.js b/tests/auto-delete/botReadyRun.test.js new file mode 100644 index 00000000..205b26a5 --- /dev/null +++ b/tests/auto-delete/botReadyRun.test.js @@ -0,0 +1,180 @@ +/* + * Covers the startup sweep in modules/auto-delete/events/botReady.js run(): + * - computes uniqueChannels excluding any channel also configured as a voice + * channel + * - bulk-deletes text-channel history beyond keepMessageCount, never touching + * pinned / non-deletable / kept messages + * - keepMessageCount=0 deletes everything (minus pinned/non-deletable) + * - empty channels are skipped + * - unfetchable channels log an error and abort + * - voice channels are bulk-cleared only when empty + * localize/main auto-stubbed. + */ +const {Collection} = require('discord.js'); +const botReady = require('../../modules/auto-delete/events/botReady'); + +function msg({ + id, + pinned = false, + deletable = true, + createdAt = new Date() + } = {}) { + return { + id, + pinned, + deletable, + createdAt + }; +} + +function makeTextChannel(messages, {name = 'general'} = {}) { + const coll = new Collection(); + messages.forEach(m => coll.set(m.id, m)); + return { + name, + messages: {fetch: jest.fn().mockResolvedValue(coll)}, + bulkDelete: jest.fn().mockResolvedValue() + }; +} + +function makeClient({ + channels = [], + voiceChannels = [], + fetchMap = {} + } = {}) { + return { + configurations: { + 'auto-delete': { + channels, + 'voice-channels': voiceChannels + } + }, + modules: {'auto-delete': {}}, + channels: {fetch: jest.fn().mockImplementation((id) => Promise.resolve(fetchMap[id] ?? null))}, + logger: {error: jest.fn()} + }; +} + +test('keepMessageCount=2 keeps the 2 newest and bulk-deletes the rest', async () => { + const newest = msg({ + id: '3', + createdAt: new Date(3000) + }); + const mid = msg({ + id: '2', + createdAt: new Date(2000) + }); + const oldest = msg({ + id: '1', + createdAt: new Date(1000) + }); + const channel = makeTextChannel([newest, mid, oldest]); + const client = makeClient({ + channels: [{ + channelID: 'c1', + keepMessageCount: '2' + }], + fetchMap: {c1: channel} + }); + await botReady.run(client); + expect(channel.bulkDelete).toHaveBeenCalledTimes(1); + const deleted = channel.bulkDelete.mock.calls[0][0]; + // Only the oldest message remains for deletion + expect([...deleted.values()].map(m => m.id)).toEqual(['1']); +}); + +test('keepMessageCount=0 deletes all non-pinned deletable messages', async () => { + const a = msg({id: '1'}); + const pinned = msg({ + id: '2', + pinned: true + }); + const undeletable = msg({ + id: '3', + deletable: false + }); + const channel = makeTextChannel([a, pinned, undeletable]); + const client = makeClient({ + channels: [{ + channelID: 'c1', + keepMessageCount: '0' + }], + fetchMap: {c1: channel} + }); + await botReady.run(client); + const deleted = channel.bulkDelete.mock.calls[0][0]; + expect([...deleted.values()].map(m => m.id)).toEqual(['1']); +}); + +test('excludes channels that are also configured as voice channels', async () => { + const textChannel = makeTextChannel([msg({id: '1'})]); + const voiceChannel = { + members: {size: 1}, + messages: {fetch: jest.fn()}, + bulkDelete: jest.fn() + }; + const client = makeClient({ + channels: [{ + channelID: 'shared', + keepMessageCount: '0' + }], + voiceChannels: [{channelID: 'shared'}], + fetchMap: {shared: voiceChannel} + }); + await botReady.run(client); + // shared is filtered out of uniqueChannels, so no text bulk-delete on it + expect(client.modules['auto-delete'].uniqueChannels).toEqual([]); +}); + +test('skips empty channels', async () => { + const channel = makeTextChannel([]); + const client = makeClient({ + channels: [{ + channelID: 'c1', + keepMessageCount: '0' + }], + fetchMap: {c1: channel} + }); + await botReady.run(client); + expect(channel.bulkDelete).not.toHaveBeenCalled(); +}); + +test('logs an error and aborts when a configured channel cannot be fetched', async () => { + const client = makeClient({ + channels: [{ + channelID: 'missing', + keepMessageCount: '0' + }], + fetchMap: {} + }); + await botReady.run(client); + expect(client.logger.error).toHaveBeenCalledTimes(1); +}); + +test('bulk-clears an empty voice channel and skips occupied ones', async () => { + const emptyVoice = { + members: {size: 0}, + messages: {fetch: jest.fn().mockResolvedValue(new Collection([['1', msg({id: '1'})]]))}, + bulkDelete: jest.fn().mockResolvedValue() + }; + const client = makeClient({ + voiceChannels: [{channelID: 'v1'}], + fetchMap: {v1: emptyVoice} + }); + await botReady.run(client); + expect(emptyVoice.bulkDelete).toHaveBeenCalledTimes(1); +}); + +test('does not clear a voice channel that still has members', async () => { + const busyVoice = { + members: {size: 3}, + messages: {fetch: jest.fn()}, + bulkDelete: jest.fn() + }; + const client = makeClient({ + voiceChannels: [{channelID: 'v1'}], + fetchMap: {v1: busyVoice} + }); + await botReady.run(client); + expect(busyVoice.bulkDelete).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/auto-messager/botReady.test.js b/tests/auto-messager/botReady.test.js new file mode 100644 index 00000000..d3f66027 --- /dev/null +++ b/tests/auto-messager/botReady.test.js @@ -0,0 +1,266 @@ +/* + * Tests for the auto-messager botReady scheduler (modules/auto-messager/events/botReady.js). + * + * The handler registers three kinds of scheduled jobs (hourly / daily / cronjob) + * via node-schedule. node-schedule is mocked so we can capture each job's + * callback and invoke it deterministically. Date is stubbed so the time-window + * filters (limitHoursTo / limitWeekDaysTo / limitDaysTo) are testable. + * + * Covers: + * - all configured jobs are registered and pushed onto client.jobs + * - hourly limitHoursTo gating (send only in the allowed hour; empty = always) + * - daily limitWeekDaysTo / limitDaysTo gating + * - missing channel => logs an error instead of sending + * - cronjob jobs send to their configured channel + */ + +const scheduledJobs = []; +jest.mock('node-schedule', () => ({ + scheduleJob: jest.fn((expr, cb) => { + const job = { + expr, + cb, + id: scheduledJobs.length + }; + scheduledJobs.push(job); + return job; + }) +})); + +const schedule = require('node-schedule'); +const botReady = require('../../modules/auto-messager/events/botReady.js'); + +function makeClient({ + hourly = [], + daily = [], + cronjob = [], + channels = {} + } = {}) { + return { + configurations: { + 'auto-messager': { + hourly, + daily, + cronjob + } + }, + channels: { + cache: {get: (id) => channels[id]} + }, + jobs: [], + logger: {error: jest.fn()} + }; +} + +function makeChannel() { + return {send: jest.fn().mockResolvedValue()}; +} + +beforeEach(() => { + scheduledJobs.length = 0; + schedule.scheduleJob.mockClear(); +}); + +function getJob(expr) { + return scheduledJobs.find(j => j.expr === expr); +} + +describe('job registration', () => { + test('registers hourly, daily and each cronjob, pushing them onto client.jobs', async () => { + const client = makeClient({ + hourly: [{ + channelID: 'h', + message: 'hi', + limitHoursTo: [] + }], + daily: [{ + channelID: 'd', + message: 'hi', + limitWeekDaysTo: [], + limitDaysTo: [] + }], + cronjob: [ + { + expression: '* * * * *', + channelID: 'c1', + message: 'a' + }, + { + expression: '0 0 * * *', + channelID: 'c2', + message: 'b' + } + ] + }); + await botReady.run(client); + + expect(getJob('1 * * * *')).toBeDefined(); // hourly + expect(getJob('1 6 * * *')).toBeDefined(); // daily + expect(getJob('* * * * *')).toBeDefined(); // cronjob 1 + expect(getJob('0 0 * * *')).toBeDefined(); // cronjob 2 + // hourly + daily + 2 cron = 4 jobs tracked + expect(client.jobs).toHaveLength(4); + }); +}); + +describe('hourly job limitHoursTo gating', () => { + test('sends when the current hour is allowed', async () => { + const channel = makeChannel(); + const client = makeClient({ + hourly: [{ + channelID: 'h', + message: 'msg', + limitHoursTo: ['9'] + }], + channels: {h: channel} + }); + await botReady.run(client); + + const spy = jest.spyOn(Date.prototype, 'getHours').mockReturnValue(9); + try { + await getJob('1 * * * *').cb(); + } finally { + spy.mockRestore(); + } + expect(channel.send).toHaveBeenCalledTimes(1); + }); + + test('does not send outside the allowed hour', async () => { + const channel = makeChannel(); + const client = makeClient({ + hourly: [{ + channelID: 'h', + message: 'msg', + limitHoursTo: ['9'] + }], + channels: {h: channel} + }); + await botReady.run(client); + + const spy = jest.spyOn(Date.prototype, 'getHours').mockReturnValue(14); + try { + await getJob('1 * * * *').cb(); + } finally { + spy.mockRestore(); + } + expect(channel.send).not.toHaveBeenCalled(); + }); + + test('an empty limitHoursTo means send every hour', async () => { + const channel = makeChannel(); + const client = makeClient({ + hourly: [{ + channelID: 'h', + message: 'msg', + limitHoursTo: [] + }], + channels: {h: channel} + }); + await botReady.run(client); + + const spy = jest.spyOn(Date.prototype, 'getHours').mockReturnValue(3); + try { + await getJob('1 * * * *').cb(); + } finally { + spy.mockRestore(); + } + expect(channel.send).toHaveBeenCalledTimes(1); + }); + + test('logs an error when the configured channel is missing', async () => { + const client = makeClient({ + hourly: [{ + channelID: 'gone', + message: 'msg', + limitHoursTo: [] + }], + channels: {} + }); + await botReady.run(client); + await getJob('1 * * * *').cb(); + expect(client.logger.error).toHaveBeenCalledTimes(1); + }); +}); + +describe('daily job gating', () => { + test('respects limitWeekDaysTo (getDay()+1)', async () => { + const channel = makeChannel(); + const client = makeClient({ + // allow only Monday: getDay()=1 -> +1 = 2 + daily: [{ + channelID: 'd', + message: 'msg', + limitWeekDaysTo: ['2'], + limitDaysTo: [] + }], + channels: {d: channel} + }); + await botReady.run(client); + const job = getJob('1 6 * * *'); + + const allow = jest.spyOn(Date.prototype, 'getDay').mockReturnValue(1); + try { + await job.cb(); + } finally { + allow.mockRestore(); + } + expect(channel.send).toHaveBeenCalledTimes(1); + + channel.send.mockClear(); + const deny = jest.spyOn(Date.prototype, 'getDay').mockReturnValue(4); + try { + await job.cb(); + } finally { + deny.mockRestore(); + } + expect(channel.send).not.toHaveBeenCalled(); + }); + + test('respects limitDaysTo (day of month)', async () => { + const channel = makeChannel(); + const client = makeClient({ + daily: [{ + channelID: 'd', + message: 'msg', + limitWeekDaysTo: [], + limitDaysTo: ['15'] + }], + channels: {d: channel} + }); + await botReady.run(client); + const job = getJob('1 6 * * *'); + + const deny = jest.spyOn(Date.prototype, 'getDate').mockReturnValue(10); + try { + await job.cb(); + } finally { + deny.mockRestore(); + } + expect(channel.send).not.toHaveBeenCalled(); + + const allow = jest.spyOn(Date.prototype, 'getDate').mockReturnValue(15); + try { + await job.cb(); + } finally { + allow.mockRestore(); + } + expect(channel.send).toHaveBeenCalledTimes(1); + }); +}); + +describe('cronjob', () => { + test('sends to the configured channel when the job fires', async () => { + const channel = makeChannel(); + const client = makeClient({ + cronjob: [{ + expression: '*/5 * * * *', + channelID: 'cc', + message: 'tick' + }], + channels: {cc: channel} + }); + await botReady.run(client); + await getJob('*/5 * * * *').cb(); + expect(channel.send).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/auto-publisher/edgeCases.test.js b/tests/auto-publisher/edgeCases.test.js new file mode 100644 index 00000000..d9377c86 --- /dev/null +++ b/tests/auto-publisher/edgeCases.test.js @@ -0,0 +1,83 @@ +/* + * Edge coverage for modules/auto-publisher/events/messageCreate.js beyond the + * main happy/mode tests: + * - missing whitelist array in whitelist mode -> defaults to [] -> skip + * - missing blacklist array in blacklist mode -> defaults to [] -> publish + * - wrong-guild and no-guild guards + * - the success reaction is scheduled for removal after 2.5s + */ +const {ChannelType} = require('discord.js'); +const handler = require('../../modules/auto-publisher/events/messageCreate'); + +function makeMsg({ + config = {}, + guildId = 'g1', + hasGuild = true, + channelType = ChannelType.GuildAnnouncement + } = {}) { + const reaction = {remove: jest.fn()}; + return { + _reaction: reaction, + guild: hasGuild ? {id: guildId} : null, + author: {bot: false}, + content: 'hello', + crosspostable: true, + channel: { + id: 'announce1', + type: channelType + }, + crosspost: jest.fn().mockResolvedValue(), + react: jest.fn().mockResolvedValue(reaction), + client: { + botReadyAt: Date.now(), + guildID: 'g1', + config: {prefix: '!'}, + configurations: {'auto-publisher': {config}} + } + }; +} + +test('whitelist mode with no whitelist array skips publishing', async () => { + const msg = makeMsg({config: {mode: 'whitelist'}}); // whitelist undefined + await handler.run(msg.client, msg); + expect(msg.crosspost).not.toHaveBeenCalled(); +}); + +test('blacklist mode with no blacklist array still publishes', async () => { + const msg = makeMsg({config: {mode: 'blacklist'}}); // blacklist undefined + await handler.run(msg.client, msg); + expect(msg.crosspost).toHaveBeenCalled(); +}); + +test('ignores messages without a guild', async () => { + const msg = makeMsg({hasGuild: false}); + await handler.run(msg.client, msg); + expect(msg.crosspost).not.toHaveBeenCalled(); +}); + +test('ignores messages from another guild', async () => { + const msg = makeMsg({guildId: 'other'}); + await handler.run(msg.client, msg); + expect(msg.crosspost).not.toHaveBeenCalled(); +}); + +test('defaults mode to "all" when unset, publishing everywhere', async () => { + const config = {}; + const msg = makeMsg({config}); + await handler.run(msg.client, msg); + expect(config.mode).toBe('all'); + expect(msg.crosspost).toHaveBeenCalled(); +}); + +test('removes the success reaction after 2.5 seconds', async () => { + jest.useFakeTimers(); + try { + const msg = makeMsg({config: {}}); + await handler.run(msg.client, msg); + expect(msg._reaction.remove).not.toHaveBeenCalled(); + jest.advanceTimersByTime(2500); + expect(msg._reaction.remove).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } +}); \ No newline at end of file diff --git a/tests/auto-publisher/messageCreate.test.js b/tests/auto-publisher/messageCreate.test.js new file mode 100644 index 00000000..32ec049a --- /dev/null +++ b/tests/auto-publisher/messageCreate.test.js @@ -0,0 +1,146 @@ +/* + * Tests for the auto-publisher messageCreate handler + * (modules/auto-publisher/events/messageCreate.js). + * + * Covers the publish gating: only crossposts in announcement channels, honors + * ignoreBots, prefix-command skip, and the blacklist / whitelist / all modes. + * Also verifies the success path reacts with a checkmark and that crosspost is + * skipped for non-crosspostable messages. + */ + +const {ChannelType} = require('discord.js'); +const handler = require('../../modules/auto-publisher/events/messageCreate.js'); + +function makeMsg({ + config = {}, + channelType = ChannelType.GuildAnnouncement, + channelId = 'announce1', + authorBot = false, + content = 'hello', + crosspostable = true + } = {}) { + return { + guild: {id: 'g1'}, + author: {bot: authorBot}, + content, + crosspostable, + channel: { + id: channelId, + type: channelType + }, + crosspost: jest.fn().mockResolvedValue(), + react: jest.fn().mockResolvedValue({remove: jest.fn()}), + client: { + botReadyAt: Date.now(), + guildID: 'g1', + config: {prefix: '!'}, + configurations: {'auto-publisher': {config}} + } + }; +} + +function clientOf(msg) { + return msg.client; +} + +test('crossposts and reacts in an announcement channel (default "all" mode)', async () => { + const msg = makeMsg({config: {}}); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).toHaveBeenCalledTimes(1); + expect(msg.react).toHaveBeenCalledWith('✅'); +}); + +test('does nothing in a non-announcement channel', async () => { + const msg = makeMsg({channelType: ChannelType.GuildText}); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).not.toHaveBeenCalled(); + expect(msg.react).not.toHaveBeenCalled(); +}); + +test('skips prefixed command messages', async () => { + const msg = makeMsg({content: '!ping'}); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).not.toHaveBeenCalled(); +}); + +test('skips bot messages when ignoreBots is set', async () => { + const msg = makeMsg({ + config: {ignoreBots: true}, + authorBot: true + }); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).not.toHaveBeenCalled(); +}); + +test('publishes bot messages when ignoreBots is not set', async () => { + const msg = makeMsg({ + config: {ignoreBots: false}, + authorBot: true + }); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).toHaveBeenCalled(); +}); + +describe('blacklist mode', () => { + test('skips a blacklisted channel', async () => { + const msg = makeMsg({ + config: { + mode: 'blacklist', + blacklist: ['announce1'] + } + }); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).not.toHaveBeenCalled(); + }); + + test('publishes a non-blacklisted channel', async () => { + const msg = makeMsg({ + config: { + mode: 'blacklist', + blacklist: ['other'] + } + }); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).toHaveBeenCalled(); + }); +}); + +describe('whitelist mode', () => { + test('publishes a whitelisted channel', async () => { + const msg = makeMsg({ + config: { + mode: 'whitelist', + whitelist: ['announce1'] + } + }); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).toHaveBeenCalled(); + }); + + test('skips a non-whitelisted channel', async () => { + const msg = makeMsg({ + config: { + mode: 'whitelist', + whitelist: ['other'] + } + }); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).not.toHaveBeenCalled(); + // It still reacts only on the publish path, so no reaction either. + expect(msg.react).not.toHaveBeenCalled(); + }); +}); + +test('reacts even when the message is not crosspostable', async () => { + const msg = makeMsg({crosspostable: false}); + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).not.toHaveBeenCalled(); + expect(msg.react).toHaveBeenCalledWith('✅'); +}); + +test('ignores messages before the bot is ready', async () => { + const msg = makeMsg(); + msg.client.botReadyAt = null; + await handler.run(clientOf(msg), msg); + expect(msg.crosspost).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/auto-thread/durations.test.js b/tests/auto-thread/durations.test.js new file mode 100644 index 00000000..ab48116a --- /dev/null +++ b/tests/auto-thread/durations.test.js @@ -0,0 +1,69 @@ +/* + * Additional edge coverage for modules/auto-thread/events/messageCreate.js: + * every threadArchiveDuration keyword maps to the right discord.js enum, an + * unknown keyword yields undefined autoArchiveDuration (passed through to + * startThread), an empty-string channels config does not crash, and a message + * in a configured channel that also has no thread still starts one with the + * configured reason. + */ +const {ThreadAutoArchiveDuration} = require('discord.js'); +const handler = require('../../modules/auto-thread/events/messageCreate'); + +function makeClient(over = {}) { + return { + botReadyAt: Date.now(), + configurations: { + 'auto-thread': { + config: { + channels: ['chan-1'], + threadName: 'Topic', + threadArchiveDuration: '1440', + ...over + } + } + } + }; +} + +function makeMessage(over = {}) { + return { + interaction: null, + system: false, + channel: {id: 'chan-1'}, + hasThread: false, + startThread: jest.fn().mockResolvedValue({}), + ...over + }; +} + +const cases = [ + ['60', ThreadAutoArchiveDuration.OneHour], + ['1440', ThreadAutoArchiveDuration.OneDay], + ['4320', ThreadAutoArchiveDuration.ThreeDays], + ['10080', ThreadAutoArchiveDuration.OneWeek], + ['MAX', ThreadAutoArchiveDuration.OneWeek] +]; + +test.each(cases)('maps duration keyword %s to its enum value', async (keyword, expected) => { + const msg = makeMessage(); + await handler.run(makeClient({threadArchiveDuration: keyword}), msg); + expect(msg.startThread.mock.calls[0][0].autoArchiveDuration).toBe(expected); +}); + +test('an unknown duration keyword passes undefined to startThread', async () => { + const msg = makeMessage(); + await handler.run(makeClient({threadArchiveDuration: 'bogus'}), msg); + expect(msg.startThread.mock.calls[0][0].autoArchiveDuration).toBeUndefined(); +}); + +test('passes a reason string to startThread', async () => { + const msg = makeMessage(); + await handler.run(makeClient(), msg); + expect(typeof msg.startThread.mock.calls[0][0].reason).toBe('string'); +}); + +test('an empty channels array never starts a thread', async () => { + const msg = makeMessage(); + await handler.run(makeClient({channels: []}), msg); + expect(msg.startThread).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/auto-thread/messageCreate.test.js b/tests/auto-thread/messageCreate.test.js new file mode 100644 index 00000000..3106434b --- /dev/null +++ b/tests/auto-thread/messageCreate.test.js @@ -0,0 +1,94 @@ +/* + * Covers the auto-thread messageCreate handler + * (modules/auto-thread/events/messageCreate.js): the guard conditions that + * suppress thread creation (bot not ready, interaction/system messages, + * non-configured channels, message already has a thread) and the happy path + * that starts a thread with the configured name and the mapped + * autoArchiveDuration. ThreadAutoArchiveDuration values come from the real + * discord.js enum; localize is auto-stubbed. + */ +const {ThreadAutoArchiveDuration} = require('discord.js'); +const handler = require('../../modules/auto-thread/events/messageCreate'); + +function makeClient(config = {}) { + return { + botReadyAt: Date.now(), + configurations: { + 'auto-thread': { + config: { + channels: ['chan-1'], + threadName: 'Discussion', + threadArchiveDuration: '1440', + ...config + } + } + } + }; +} + +function makeMessage(overrides = {}) { + return { + interaction: null, + system: false, + channel: {id: 'chan-1'}, + hasThread: false, + startThread: jest.fn().mockResolvedValue({}), + ...overrides + }; +} + +test('starts a thread in a configured channel with the mapped duration', async () => { + const client = makeClient(); + const msg = makeMessage(); + await handler.run(client, msg); + expect(msg.startThread).toHaveBeenCalledTimes(1); + const arg = msg.startThread.mock.calls[0][0]; + expect(arg.name).toBe('Discussion'); + expect(arg.autoArchiveDuration).toBe(ThreadAutoArchiveDuration.OneDay); +}); + +test('maps the MAX duration keyword to one week', async () => { + const client = makeClient({threadArchiveDuration: 'MAX'}); + const msg = makeMessage(); + await handler.run(client, msg); + expect(msg.startThread.mock.calls[0][0].autoArchiveDuration).toBe(ThreadAutoArchiveDuration.OneWeek); +}); + +test('does nothing before the bot is ready', async () => { + const client = makeClient(); + client.botReadyAt = null; + const msg = makeMessage(); + await handler.run(client, msg); + expect(msg.startThread).not.toHaveBeenCalled(); +}); + +test('ignores interaction responses and system messages', async () => { + const client = makeClient(); + const interactionMsg = makeMessage({interaction: {id: 'x'}}); + const systemMsg = makeMessage({system: true}); + await handler.run(client, interactionMsg); + await handler.run(client, systemMsg); + expect(interactionMsg.startThread).not.toHaveBeenCalled(); + expect(systemMsg.startThread).not.toHaveBeenCalled(); +}); + +test('ignores messages in non-configured channels', async () => { + const client = makeClient(); + const msg = makeMessage({channel: {id: 'other-channel'}}); + await handler.run(client, msg); + expect(msg.startThread).not.toHaveBeenCalled(); +}); + +test('does not create a second thread when one already exists', async () => { + const client = makeClient(); + const msg = makeMessage({hasThread: true}); + await handler.run(client, msg); + expect(msg.startThread).not.toHaveBeenCalled(); +}); + +test('tolerates a missing channels array in config', async () => { + const client = makeClient({channels: undefined}); + const msg = makeMessage(); + await expect(handler.run(client, msg)).resolves.toBeUndefined(); + expect(msg.startThread).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/betterstatus/botReady.test.js b/tests/betterstatus/botReady.test.js new file mode 100644 index 00000000..6168e259 --- /dev/null +++ b/tests/betterstatus/botReady.test.js @@ -0,0 +1,203 @@ +/* + * Covers the betterstatus botReady handler (modules/betterstatus/events/botReady.js): + * - the initial setActivity with the replaced user_presence string + * - placeholder replacement (%memberCount%, %onlineMemberCount%, %channelCount%, + * %roleCount%, %randomOnlineMemberTag%, %randomMemberTag%) against real-ish + * discord.js Collections + * - the interval registration (enableInterval) and its >=5s clamp + * - botStatus !== 'ONLINE' calling setPresence + * - the non-PLAYING / non-interval extra setActivity branch + * - the streaming url being attached only for STREAMING activity in the interval + * formatDiscordUserName comes from the real helpers; localize/main auto-stubbed. + */ +const {Collection} = require('discord.js'); +const botReady = require('../../modules/betterstatus/events/botReady'); + +function makeMember({ + bot = false, + status = 'online', + username = 'user', + discriminator = '0' + } = {}) { + return { + presence: status ? {status} : null, + user: { + bot, + username, + discriminator + } + }; +} + +function makeClient(config, { + members = [], + roleCount = 3, + channelCount = 4, + memberCount = 10 +} = {}) { + const cache = new Collection(); + members.forEach((m, i) => cache.set(String(i), m)); + return { + intervals: [], + config: {user_presence: 'Hi %memberCount%'}, + configurations: {betterstatus: {config}}, + guild: { + memberCount, + members: {cache}, + channels: {cache: new Collection(Array.from({length: channelCount}, (_, i) => [String(i), {}]))}, + roles: {fetch: jest.fn().mockResolvedValue(new Collection(Array.from({length: roleCount}, (_, i) => [String(i), {}])))} + }, + user: { + username: 'Bot', + setActivity: jest.fn().mockResolvedValue(), + setPresence: jest.fn().mockResolvedValue() + } + }; +} + +const baseConf = (over = {}) => ({ + activityType: 'PLAYING', + botStatus: 'ONLINE', + enableInterval: false, + interval: 30, + intervalStatuses: [], + streamingLink: '', + ...over +}); + +afterEach(() => jest.useRealTimers()); + +test('sets the initial activity with the replaced presence string', async () => { + const client = makeClient(baseConf(), {members: [makeMember(), makeMember({bot: true})]}); + await botReady.run(client); + expect(client.user.setActivity).toHaveBeenCalled(); + const firstArg = client.user.setActivity.mock.calls[0][0]; + expect(firstArg).toBe('Hi 10'); // %memberCount% replaced with guild.memberCount +}); + +test('replaces member/channel/role placeholders', async () => { + const client = makeClient(baseConf(), { + members: [makeMember({status: 'online'}), makeMember({status: 'dnd'}), makeMember({status: null})], + roleCount: 7, + channelCount: 5, + memberCount: 42 + }); + client.config.user_presence = 'M:%memberCount% O:%onlineMemberCount% C:%channelCount% R:%roleCount%'; + await botReady.run(client); + const text = client.user.setActivity.mock.calls[0][0]; + // onlineMemberCount = members with presence and not bot = 2 + expect(text).toBe('M:42 O:2 C:5 R:7'); +}); + +test('returns "Invalid status" for a falsy presence string', async () => { + const client = makeClient(baseConf(), {members: [makeMember()]}); + client.config.user_presence = ''; + await botReady.run(client); + expect(client.user.setActivity.mock.calls[0][0]).toBe('Invalid status'); +}); + +test('replaces %randomMemberTag% using the username#discriminator form', async () => { + const client = makeClient(baseConf(), { + members: [makeMember({ + username: 'alice', + discriminator: '1234' + })] + }); + client.config.user_presence = 'T:%randomMemberTag%'; + await botReady.run(client); + expect(client.user.setActivity.mock.calls[0][0]).toBe('T:alice#1234'); +}); + +test('registers an interval when enableInterval is set and clamps below 5s', async () => { + jest.useFakeTimers(); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const client = makeClient(baseConf({ + enableInterval: true, + interval: 2, + intervalStatuses: ['Status A'] + }), { + members: [makeMember()] + }); + await botReady.run(client); + expect(client.intervals).toHaveLength(1); + // interval 2s -> clamped to 5000ms + expect(setIntervalSpy.mock.calls[0][1]).toBe(5000); + setIntervalSpy.mockRestore(); +}); + +test('interval uses interval*1000 when >= 5s', async () => { + jest.useFakeTimers(); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const client = makeClient(baseConf({ + enableInterval: true, + interval: 30, + intervalStatuses: ['x'] + }), { + members: [makeMember()] + }); + await botReady.run(client); + expect(setIntervalSpy.mock.calls[0][1]).toBe(30000); + setIntervalSpy.mockRestore(); +}); + +test('the interval callback sets a random activity from intervalStatuses', async () => { + jest.useFakeTimers(); + const client = makeClient(baseConf({ + enableInterval: true, + interval: 30, + intervalStatuses: ['Only One'] + }), { + members: [makeMember()] + }); + await botReady.run(client); + client.user.setActivity.mockClear(); + await jest.advanceTimersByTimeAsync(30000); + expect(client.user.setActivity).toHaveBeenCalled(); + expect(client.user.setActivity.mock.calls[0][0]).toBe('Only One'); +}); + +test('sets presence when botStatus is not ONLINE', async () => { + const client = makeClient(baseConf({botStatus: 'dnd'}), {members: [makeMember()]}); + await botReady.run(client); + expect(client.user.setPresence).toHaveBeenCalledWith({status: 'dnd'}); +}); + +test('does not call setPresence when botStatus is ONLINE', async () => { + const client = makeClient(baseConf({botStatus: 'ONLINE'}), {members: [makeMember()]}); + await botReady.run(client); + expect(client.user.setPresence).not.toHaveBeenCalled(); +}); + +test('non-PLAYING activity without interval triggers a second setActivity with raw presence', async () => { + const client = makeClient(baseConf({ + activityType: 'WATCHING', + enableInterval: false + }), {members: [makeMember()]}); + await botReady.run(client); + // First call: replaced string. Second call: raw client.config.user_presence + expect(client.user.setActivity).toHaveBeenCalledTimes(2); + expect(client.user.setActivity.mock.calls[1][0]).toBe(client.config.user_presence); +}); + +test('attaches a streaming url for STREAMING activity in the extra setActivity', async () => { + const client = makeClient( + baseConf({ + activityType: 'STREAMING', + enableInterval: false, + streamingLink: 'https://twitch.tv/x' + }), + {members: [makeMember()]} + ); + await botReady.run(client); + const secondOpts = client.user.setActivity.mock.calls[1][1]; + expect(secondOpts.url).toBe('https://twitch.tv/x'); +}); + +test('PLAYING activity without interval does NOT do a second setActivity', async () => { + const client = makeClient(baseConf({ + activityType: 'PLAYING', + enableInterval: false + }), {members: [makeMember()]}); + await botReady.run(client); + expect(client.user.setActivity).toHaveBeenCalledTimes(1); +}); \ No newline at end of file diff --git a/tests/betterstatus/guildMemberAdd.test.js b/tests/betterstatus/guildMemberAdd.test.js new file mode 100644 index 00000000..32006ea6 --- /dev/null +++ b/tests/betterstatus/guildMemberAdd.test.js @@ -0,0 +1,65 @@ +/* + * Covers the betterstatus guildMemberAdd handler + * (modules/betterstatus/events/guildMemberAdd.js): when changeOnUserJoin is on, + * it sets the bot activity to userJoinStatus with %tag%/%username%/%memberCount% + * replaced; when off, it does nothing. formatDiscordUserName is the real helper. + */ +const {ActivityType} = require('discord.js'); +const handler = require('../../modules/betterstatus/events/guildMemberAdd'); + +function makeClient(config) { + return { + configurations: {betterstatus: {config}}, + user: {setActivity: jest.fn().mockResolvedValue()} + }; +} + +function makeMember({ + username = 'newbie', + memberCount = 100 + } = {}) { + return { + user: { + username, + discriminator: '0' + }, + guild: {memberCount} + }; +} + +test('changes activity on join, replacing username and memberCount', async () => { + const client = makeClient({ + changeOnUserJoin: true, + userJoinStatus: 'Welcome %username% (%memberCount%)', + activityType: 'WATCHING' + }); + const member = makeMember({ + username: 'zoe', + memberCount: 250 + }); + await handler.run(client, member); + expect(client.user.setActivity).toHaveBeenCalledTimes(1); + const [text, opts] = client.user.setActivity.mock.calls[0]; + expect(text).toBe('Welcome zoe (250)'); + expect(opts.type).toBe(ActivityType.Watching); +}); + +test('replaces the %tag% placeholder', async () => { + const client = makeClient({ + changeOnUserJoin: true, + userJoinStatus: 'Tag: %tag%', + activityType: 'PLAYING' + }); + await handler.run(client, makeMember({username: 'bob'})); + expect(client.user.setActivity.mock.calls[0][0]).toContain('bob'); +}); + +test('does nothing when changeOnUserJoin is disabled', async () => { + const client = makeClient({ + changeOnUserJoin: false, + userJoinStatus: 'x', + activityType: 'PLAYING' + }); + await handler.run(client, makeMember()); + expect(client.user.setActivity).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/betterstatus/status.test.js b/tests/betterstatus/status.test.js new file mode 100644 index 00000000..83fdc284 --- /dev/null +++ b/tests/betterstatus/status.test.js @@ -0,0 +1,98 @@ +/* + * Covers the /status command handler (modules/betterstatus/commands/status.js): + * mapping the user-facing activity-type string to the discord.js ActivityType + * enum, attaching the streaming URL only for STREAMING activities, passing + * through the bot presence status, and the ephemeral confirmation reply. Also + * exercises the config.disabled() toggle. localize is auto-stubbed. + */ +const {ActivityType} = require('discord.js'); +const cmd = require('../../modules/betterstatus/commands/status'); + +function makeInteraction(opts) { + return { + options: {getString: (name) => (name in opts ? opts[name] : null)}, + client: {user: {setPresence: jest.fn().mockResolvedValue()}}, + reply: jest.fn().mockResolvedValue() + }; +} + +test('maps WATCHING to the ActivityType.Watching enum and sets presence', async () => { + const i = makeInteraction({ + 'activity-type': 'WATCHING', + 'bot-status': 'idle', + text: 'the server' + }); + await cmd.run(i); + const payload = i.client.user.setPresence.mock.calls[0][0]; + expect(payload.status).toBe('idle'); + expect(payload.activities[0].name).toBe('the server'); + expect(payload.activities[0].type).toBe(ActivityType.Watching); + expect(payload.activities[0].url).toBeNull(); +}); + +test('attaches the streaming link only for STREAMING activities', async () => { + const i = makeInteraction({ + 'activity-type': 'STREAMING', + 'bot-status': 'online', + text: 'live', + 'streaming-link': 'https://twitch.tv/x' + }); + await cmd.run(i); + const activity = i.client.user.setPresence.mock.calls[0][0].activities[0]; + expect(activity.type).toBe(ActivityType.Streaming); + expect(activity.url).toBe('https://twitch.tv/x'); +}); + +test('ignores a streaming link for non-streaming activities', async () => { + const i = makeInteraction({ + 'activity-type': 'PLAYING', + 'bot-status': 'dnd', + text: 'a game', + 'streaming-link': 'https://twitch.tv/x' + }); + await cmd.run(i); + expect(i.client.user.setPresence.mock.calls[0][0].activities[0].url).toBeNull(); +}); + +test('maps CUSTOM and LISTENING activity types', async () => { + const custom = makeInteraction({ + 'activity-type': 'CUSTOM', + 'bot-status': 'online', + text: 'hi' + }); + await cmd.run(custom); + expect(custom.client.user.setPresence.mock.calls[0][0].activities[0].type).toBe(ActivityType.Custom); + + const listening = makeInteraction({ + 'activity-type': 'LISTENING', + 'bot-status': 'online', + text: 'music' + }); + await cmd.run(listening); + expect(listening.client.user.setPresence.mock.calls[0][0].activities[0].type).toBe(ActivityType.Listening); +}); + +test('confirms the change with an ephemeral reply containing the status text', async () => { + const i = makeInteraction({ + 'activity-type': 'PLAYING', + 'bot-status': 'online', + text: 'chess' + }); + await cmd.run(i); + const reply = i.reply.mock.calls[0][0]; + expect(reply.ephemeral).toBe(true); + expect(reply.content).toContain('s=chess'); +}); + +describe('config.disabled toggle', () => { + function clientWith(enableStatusCommand) { + return {configurations: {betterstatus: {config: {enableStatusCommand}}}}; + } + + test('command is disabled when enableStatusCommand is false', () => { + expect(cmd.config.disabled(clientWith(false))).toBe(true); + }); + test('command is enabled when enableStatusCommand is true', () => { + expect(cmd.config.disabled(clientWith(true))).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/channel-stats/botReadyRun.test.js b/tests/channel-stats/botReadyRun.test.js new file mode 100644 index 00000000..22a52676 --- /dev/null +++ b/tests/channel-stats/botReadyRun.test.js @@ -0,0 +1,190 @@ +/* + * Covers the run() orchestration in modules/channel-stats/events/botReady.js + * (the placeholder engine itself is covered by channelNameReplacer.test.js): + * - renames each configured channel at startup only when the name actually + * changes + * - warns for non-voice/non-category channels + * - skips channels that cannot be fetched + * - registers an update interval per channel, clamped to a >= 5 minute floor + * - the interval re-renders and renames on change, guarding against overlap + * formatDate/localize are real/auto-stubbed. + */ +const { + ChannelType, + Collection +} = require('discord.js'); +const botReady = require('../../modules/channel-stats/events/botReady'); + +function makeGuild({ + members = new Collection(), + channelCount = 3, + roleCount = 2 + } = {}) { + return { + members: {cache: members}, + channels: {cache: new Collection(Array.from({length: channelCount}, (_, i) => [String(i), {}]))}, + roles: {cache: new Collection(Array.from({length: roleCount}, (_, i) => [String(i), {}]))}, + emojis: {cache: new Collection()}, + premiumSubscriptionCount: 0, + premiumTier: 0 + }; +} + +function makeChannel({ + name, + type = ChannelType.GuildVoice, + guild + }) { + return { + id: 'ch1', + name, + type, + guild, + setName: jest.fn().mockResolvedValue() + }; +} + +function makeClient({ + channels, + fetchMap, + guild + }) { + return { + configurations: {'channel-stats': {channels}}, + intervals: [], + channels: {fetch: jest.fn().mockImplementation((id) => Promise.resolve(fetchMap[id] ?? null))}, + guild, + logger: {warn: jest.fn()} + }; +} + +afterEach(() => jest.useRealTimers()); + +test('renames a channel at startup when the rendered name differs', async () => { + jest.useFakeTimers(); + const guild = makeGuild({channelCount: 7}); + const channel = makeChannel({ + name: 'Channels: 0', + guild + }); + const client = makeClient({ + channels: [{ + channelID: 'ch1', + channelName: 'Channels: %channelCount%', + updateInterval: 5 + }], + fetchMap: {ch1: channel}, + guild + }); + await botReady.run(client); + expect(channel.setName).toHaveBeenCalledTimes(1); + expect(channel.setName.mock.calls[0][0]).toBe('Channels: 7'); +}); + +test('does not rename when the rendered name already matches', async () => { + jest.useFakeTimers(); + const guild = makeGuild({channelCount: 7}); + const channel = makeChannel({ + name: 'Channels: 7', + guild + }); + const client = makeClient({ + channels: [{ + channelID: 'ch1', + channelName: 'Channels: %channelCount%', + updateInterval: 5 + }], + fetchMap: {ch1: channel}, + guild + }); + await botReady.run(client); + expect(channel.setName).not.toHaveBeenCalled(); +}); + +test('warns for a non-voice / non-category channel', async () => { + jest.useFakeTimers(); + const guild = makeGuild(); + const channel = makeChannel({ + name: 'x', + type: ChannelType.GuildText, + guild + }); + const client = makeClient({ + channels: [{ + channelID: 'ch1', + channelName: 'x', + updateInterval: 5 + }], + fetchMap: {ch1: channel}, + guild + }); + await botReady.run(client); + expect(client.logger.warn).toHaveBeenCalledTimes(1); +}); + +test('skips channels that cannot be fetched', async () => { + jest.useFakeTimers(); + const guild = makeGuild(); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const client = makeClient({ + channels: [{ + channelID: 'gone', + channelName: 'x', + updateInterval: 5 + }], + fetchMap: {}, + guild + }); + await botReady.run(client); + expect(setIntervalSpy).not.toHaveBeenCalled(); + setIntervalSpy.mockRestore(); +}); + +test('registers an interval clamped to a 5-minute floor', async () => { + jest.useFakeTimers(); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const guild = makeGuild(); + const channel = makeChannel({ + name: 'x', + guild + }); + const client = makeClient({ + channels: [{ + channelID: 'ch1', + channelName: 'x', + updateInterval: 1 + }], + fetchMap: {ch1: channel}, + guild + }); + await botReady.run(client); + expect(client.intervals).toHaveLength(1); + // updateInterval 1 -> clamped to 5 minutes (300000ms) + expect(setIntervalSpy.mock.calls[0][1]).toBe(300000); + setIntervalSpy.mockRestore(); +}); + +test('the interval re-renders and renames on change', async () => { + jest.useFakeTimers(); + const guild = makeGuild({channelCount: 4}); + const channel = makeChannel({ + name: 'Channels: 4', + guild + }); + const client = makeClient({ + channels: [{ + channelID: 'ch1', + channelName: 'Channels: %channelCount%', + updateInterval: 5 + }], + fetchMap: {ch1: channel}, + guild + }); + await botReady.run(client); + expect(channel.setName).not.toHaveBeenCalled(); // already matches + // Now the channel count grows; the interval should rename + guild.channels.cache.set('99', {}); + channel.name = 'Channels: 4'; + await jest.advanceTimersByTimeAsync(300000); + expect(channel.setName).toHaveBeenCalledWith('Channels: 5', expect.any(String)); +}); \ No newline at end of file diff --git a/tests/channel-stats/channelNameReplacer.test.js b/tests/channel-stats/channelNameReplacer.test.js new file mode 100644 index 00000000..d8d6cebe --- /dev/null +++ b/tests/channel-stats/channelNameReplacer.test.js @@ -0,0 +1,143 @@ +/* + * Covers channelNameReplacer from modules/channel-stats/events/botReady.js: + * the placeholder-substitution engine that turns templates like + * "Members: %memberCount%" into live counts. Uses real discord.js Collections + * to back the member/role/channel caches so the .filter().size paths run for + * real. Verifies user/member/bot counts, presence-based counts (online/dnd/ + * idle/offline), role-scoped counts (%userWithRoleCount-ID%) including the + * recursive multi-role replacement, and guild-level counts. main/localize are + * auto-stubbed by jest.config. + */ +const {Collection} = require('discord.js'); +const {channelNameReplacer} = require('../../modules/channel-stats/events/botReady'); + +function member({ + bot = false, + status = null, + roles = [], + premium = false + } = {}) { + return { + user: {bot}, + presence: status ? {status} : null, + premiumSinceTimestamp: premium ? Date.now() : null, + roles: {cache: {has: (id) => roles.includes(id)}} + }; +} + +function buildClient(members) { + const cache = new Collection(); + members.forEach((m, i) => cache.set(String(i), m)); + const guild = { + channels: {cache: new Collection(Array.from({length: 4}, (_, i) => [String(i), {}]))}, + roles: {cache: new Collection(Array.from({length: 3}, (_, i) => [String(i), {}]))}, + emojis: {cache: new Collection(Array.from({length: 5}, (_, i) => [String(i), {}]))}, + premiumSubscriptionCount: 7, + premiumTier: 2 + }; + return { + client: {guild: {members: {cache}}}, + channel: {guild} + }; +} + +test('substitutes total user count and human member count (bots excluded)', async () => { + const { + client, + channel + } = buildClient([ + member(), member(), member({bot: true}) + ]); + expect(await channelNameReplacer(client, channel, 'U:%userCount%')).toBe('U:3'); + expect(await channelNameReplacer(client, channel, 'M:%memberCount%')).toBe('M:2'); + expect(await channelNameReplacer(client, channel, 'B:%botCount%')).toBe('B:1'); +}); + +test('counts presence states for online/offline/dnd/idle', async () => { + const { + client, + channel + } = buildClient([ + member({status: 'online'}), + member({status: 'dnd'}), + member({status: 'idle'}), + member({status: 'offline'}), + member() // no presence -> offline + ]); + expect(await channelNameReplacer(client, channel, '%onlineUserCount%')).toBe('3'); // online,dnd,idle + expect(await channelNameReplacer(client, channel, '%dndCount%')).toBe('1'); + expect(await channelNameReplacer(client, channel, '%awayCount%')).toBe('1'); + expect(await channelNameReplacer(client, channel, '%offlineCount%')).toBe('2'); +}); + +test('online member count excludes bots', async () => { + const { + client, + channel + } = buildClient([ + member({status: 'online'}), + member({ + status: 'online', + bot: true + }) + ]); + expect(await channelNameReplacer(client, channel, '%onlineMemberCount%')).toBe('1'); +}); + +test('role-scoped counts resolve a specific role id', async () => { + const { + client, + channel + } = buildClient([ + member({roles: ['role-a']}), + member({ + roles: ['role-a'], + status: 'online' + }), + member({roles: ['role-b']}) + ]); + expect(await channelNameReplacer(client, channel, '%userWithRoleCount-role-a%')).toBe('2'); + expect(await channelNameReplacer(client, channel, '%onlineUserWithRoleCount-role-a%')).toBe('1'); +}); + +test('replaces multiple distinct role placeholders recursively', async () => { + const { + client, + channel + } = buildClient([ + member({roles: ['x']}), + member({roles: ['y']}), + member({roles: ['y']}) + ]); + const out = await channelNameReplacer(client, channel, '%userWithRoleCount-x% / %userWithRoleCount-y%'); + expect(out).toBe('1 / 2'); +}); + +test('substitutes guild-level counts', async () => { + const { + client, + channel + } = buildClient([member()]); + expect(await channelNameReplacer(client, channel, '%channelCount%')).toBe('4'); + expect(await channelNameReplacer(client, channel, '%roleCount%')).toBe('3'); + expect(await channelNameReplacer(client, channel, '%emojiCount%')).toBe('5'); + expect(await channelNameReplacer(client, channel, '%guildBoosts%')).toBe('7'); +}); + +test('counts boosters via premiumSinceTimestamp', async () => { + const { + client, + channel + } = buildClient([ + member({premium: true}), member({premium: true}), member() + ]); + expect(await channelNameReplacer(client, channel, '%boosterCount%')).toBe('2'); +}); + +test('trims surrounding whitespace from the result', async () => { + const { + client, + channel + } = buildClient([member()]); + expect(await channelNameReplacer(client, channel, ' hello ')).toBe('hello'); +}); \ No newline at end of file diff --git a/tests/color-me/colorValidation.test.js b/tests/color-me/colorValidation.test.js new file mode 100644 index 00000000..18f93954 --- /dev/null +++ b/tests/color-me/colorValidation.test.js @@ -0,0 +1,74 @@ +/* + * Covers the colour-validation helper extracted from modules/color-me/commands/ + * color-me.js. Verifies hex normalisation (prefixing '#'), strict 6-digit hex + * validation with the cancel/editReply path on bad input, and the default + * gold colour when no colour option is supplied. embedType output isn't + * asserted (it's a real helper); we assert the {roleColor, cancel} contract and + * whether the user was warned. main/localize are auto-stubbed by jest.config. + */ +const {color} = require('../../modules/color-me/commands/color-me'); + +function makeInteraction(colorOption) { + return { + options: {getString: (name) => (name === 'color' ? colorOption : null)}, + editReply: jest.fn().mockResolvedValue() + }; +} + +const strings = {invalidColor: 'invalid'}; + +test('returns default gold colour and no cancel when no colour is given', async () => { + const interaction = makeInteraction(null); + const result = await color(interaction, strings); + expect(result).toEqual({ + roleColor: 0xF1C40F, + cancel: false + }); + expect(interaction.editReply).not.toHaveBeenCalled(); +}); + +test('accepts a valid hex with leading #', async () => { + const interaction = makeInteraction('#1A2B3C'); + const result = await color(interaction, strings); + expect(result).toEqual({ + roleColor: '#1A2B3C', + cancel: false + }); + expect(interaction.editReply).not.toHaveBeenCalled(); +}); + +test('prefixes a missing # before validating', async () => { + const interaction = makeInteraction('ABCDEF'); + const result = await color(interaction, strings); + expect(result.roleColor).toBe('#ABCDEF'); + expect(result.cancel).toBe(false); +}); + +test('accepts lowercase hex (case-insensitive)', async () => { + const result = await color(makeInteraction('abcdef'), strings); + expect(result).toEqual({ + roleColor: '#abcdef', + cancel: false + }); +}); + +test('rejects a 3-digit hex shorthand and warns the user', async () => { + const interaction = makeInteraction('#FFF'); + const result = await color(interaction, strings); + expect(result.cancel).toBe(true); + expect(interaction.editReply).toHaveBeenCalledTimes(1); +}); + +test('rejects hex containing non-hex characters', async () => { + const interaction = makeInteraction('GGGGGG'); + const result = await color(interaction, strings); + expect(result.cancel).toBe(true); + expect(result.roleColor).toBe('#GGGGGG'); + expect(interaction.editReply).toHaveBeenCalledTimes(1); +}); + +test('rejects an over-long hex value', async () => { + const interaction = makeInteraction('#1234567'); + const result = await color(interaction, strings); + expect(result.cancel).toBe(true); +}); \ No newline at end of file diff --git a/tests/color-me/command.test.js b/tests/color-me/command.test.js new file mode 100644 index 00000000..d457d545 --- /dev/null +++ b/tests/color-me/command.test.js @@ -0,0 +1,85 @@ +/* + * Covers modules/color-me/commands/color-me.js orchestration beyond colour + * validation: + * - beforeSubcommand defers the reply ephemerally + * - the remove subcommand: deletes the user's colour role when it exists and + * replies; stays quiet when no record / role is gone + * The heavy "manage" subcommand depends on the shared main-stub client and live + * cooldown DB access; here we focus on the standalone, deterministic paths. + * embedType is the real helper; localize/main auto-stubbed. + */ +const cmd = require('../../modules/color-me/commands/color-me'); + +const strings = {removed: 'removed!'}; + +function makeModel(found) { + return {findOne: jest.fn().mockResolvedValue(found)}; +} + +function makeInteraction({ + found, + roleExists = true, + role + } = {}) { + const resolvedRole = role || {delete: jest.fn()}; + return { + member: { + id: 'm1', + user: {username: 'alice'} + }, + guild: { + roles: { + cache: {find: () => (roleExists ? resolvedRole : undefined)}, + resolve: () => resolvedRole + } + }, + client: { + configurations: {'color-me': {strings}}, + models: {'color-me': {Role: makeModel(found)}} + }, + editReply: jest.fn().mockResolvedValue() + }; +} + +describe('beforeSubcommand', () => { + test('defers the reply ephemerally', async () => { + const interaction = {deferReply: jest.fn().mockResolvedValue()}; + await cmd.beforeSubcommand(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ephemeral: true}); + }); +}); + +describe('remove subcommand', () => { + test('deletes the colour role and replies when it exists', async () => { + const role = {delete: jest.fn()}; + const interaction = makeInteraction({ + found: {roleID: 'r1'}, + roleExists: true, + role + }); + await cmd.subcommands.remove(interaction); + expect(role.delete).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledTimes(1); + }); + + test('does nothing when the user has no stored role record', async () => { + const interaction = makeInteraction({found: null}); + await cmd.subcommands.remove(interaction); + expect(interaction.editReply).not.toHaveBeenCalled(); + }); + + test('does not reply when the stored role no longer exists in the guild', async () => { + const interaction = makeInteraction({ + found: {roleID: 'gone'}, + roleExists: false + }); + await cmd.subcommands.remove(interaction); + expect(interaction.editReply).not.toHaveBeenCalled(); + }); +}); + +test('exposes the color-me slash command config with manage + remove', () => { + expect(cmd.config.name).toBe('color-me'); + const subs = cmd.config.options.map(o => o.name); + expect(subs).toEqual(['manage', 'remove']); +}); \ No newline at end of file diff --git a/tests/color-me/guildMemberUpdate.test.js b/tests/color-me/guildMemberUpdate.test.js new file mode 100644 index 00000000..59230e5f --- /dev/null +++ b/tests/color-me/guildMemberUpdate.test.js @@ -0,0 +1,237 @@ +/* + * Covers modules/color-me/events/guildMemberUpdate.js: the boost-driven role + * lifecycle. + * - guards: bot not ready, foreign guild + * - removeOnUnboost: deletes the colour role when a member stops boosting + * - recreateRole: re-creates the stored colour role when a member starts + * boosting and the role no longer exists, then persists the new role id + * - rolePosition handling (resolve vs default 0) + * localize/main are auto-stubbed. + */ +const handler = require('../../modules/color-me/events/guildMemberUpdate'); + +function makeRoleModel(found) { + return { + findOne: jest.fn().mockResolvedValue(found), + update: jest.fn().mockResolvedValue() + }; +} + +function makeGuild({ + roleExists = false, + resolvedRole, + positionRole + } = {}) { + return { + id: 'g1', + roles: { + cache: {find: () => (roleExists ? resolvedRole : undefined)}, + resolve: (id) => (id === 'pos-role' ? positionRole : resolvedRole), + create: jest.fn().mockResolvedValue({id: 'new-role-id'}) + } + }; +} + +function makeClient({ + config, + found, + guild + }) { + return { + botReadyAt: Date.now(), + guild: guild || { + id: 'g1', + roles: {create: jest.fn().mockResolvedValue({id: 'new-role-id'})} + }, + configurations: {'color-me': {config}}, + models: {'color-me': {Role: makeRoleModel(found)}} + }; +} + +const conf = (over = {}) => ({ + rolePosition: null, + removeOnUnboost: false, + recreateRole: false, + listRoles: false, + ...over +}); + +function member({ + id = 'u1', + premium = null, + username = 'name' + } = {}) { + return { + id, + premiumSince: premium, + user: { + id, + username + }, + guild: makeGuild() + }; +} + +test('does nothing before the bot is ready', async () => { + const client = makeClient({ + config: conf(), + found: null + }); + client.botReadyAt = null; + const old = member(), neu = member(); + await handler.run(client, old, neu); + expect(client.models['color-me'].Role.findOne).not.toHaveBeenCalled(); +}); + +test('ignores updates from a foreign guild', async () => { + const client = makeClient({config: conf()}); + const old = member(); + const neu = member(); + neu.guild = { + id: 'other', + roles: {resolve: () => ({position: 0})} + }; + await handler.run(client, old, neu); + expect(client.models['color-me'].Role.findOne).not.toHaveBeenCalled(); +}); + +describe('removeOnUnboost', () => { + test('deletes the colour role when a member stops boosting', async () => { + const role = {delete: jest.fn()}; + const guild = makeGuild({ + roleExists: true, + resolvedRole: role + }); + const client = makeClient({ + config: conf({removeOnUnboost: true}), + found: {roleID: 'r1'} + }); + const old = member({premium: new Date()}); + const neu = member({premium: null}); + neu.guild = guild; + old.guild = guild; + await handler.run(client, old, neu); + expect(role.delete).toHaveBeenCalled(); + }); + + test('does nothing when the member is still boosting', async () => { + const role = {delete: jest.fn()}; + const guild = makeGuild({ + roleExists: true, + resolvedRole: role + }); + const client = makeClient({ + config: conf({removeOnUnboost: true}), + found: {roleID: 'r1'} + }); + const old = member({premium: new Date()}); + const neu = member({premium: new Date()}); + neu.guild = guild; + await handler.run(client, old, neu); + expect(role.delete).not.toHaveBeenCalled(); + }); + + test('skips deletion when the user has no stored role', async () => { + const client = makeClient({ + config: conf({removeOnUnboost: true}), + found: null + }); + const old = member({premium: new Date()}); + const neu = member({premium: null}); + await handler.run(client, old, neu); + // findOne resolved null -> nothing to delete, no throw + expect(client.models['color-me'].Role.findOne).toHaveBeenCalled(); + }); +}); + +describe('recreateRole', () => { + test('recreates a missing colour role when a member starts boosting and persists the new id', async () => { + const guild = makeGuild({roleExists: false}); + const client = makeClient({ + config: conf({recreateRole: true}), + found: { + roleID: 'old-r', + name: 'My Colour', + color: '#abcdef' + }, + guild + }); + client.guild = guild; + const old = member({premium: null}); + const neu = member({premium: new Date()}); + neu.guild = guild; + await handler.run(client, old, neu); + expect(guild.roles.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'My Colour', + color: '#abcdef' + })); + expect(client.models['color-me'].Role.update).toHaveBeenCalledWith( + {roleID: 'new-role-id'}, + {where: {userID: 'u1'}} + ); + }); + + test('does not recreate when the role still exists', async () => { + const existingRole = {id: 'old-r'}; + const guild = makeGuild({ + roleExists: true, + resolvedRole: existingRole + }); + const client = makeClient({ + config: conf({recreateRole: true}), + found: { + roleID: 'old-r', + name: 'X', + color: '#000000' + }, + guild + }); + client.guild = guild; + const old = member({premium: null}); + const neu = member({premium: new Date()}); + neu.guild = guild; + await handler.run(client, old, neu); + expect(guild.roles.create).not.toHaveBeenCalled(); + }); + + test('does nothing on recreate when there is no stored record', async () => { + const guild = makeGuild({roleExists: false}); + const client = makeClient({ + config: conf({recreateRole: true}), + found: null, + guild + }); + client.guild = guild; + const old = member({premium: null}); + const neu = member({premium: new Date()}); + neu.guild = guild; + await handler.run(client, old, neu); + expect(guild.roles.create).not.toHaveBeenCalled(); + }); +}); + +test('resolves the configured rolePosition for the new role position', async () => { + const positionRole = {position: 12}; + const guild = makeGuild({ + roleExists: false, + positionRole + }); + const client = makeClient({ + config: conf({ + recreateRole: true, + rolePosition: 'pos-role' + }), + found: { + roleID: 'old', + name: 'n', + color: '#111111' + }, + guild + }); + client.guild = guild; + const old = member({premium: null}); + const neu = member({premium: new Date()}); + neu.guild = guild; + await handler.run(client, old, neu); + expect(guild.roles.create.mock.calls[0][0].position).toBe(12); +}); \ No newline at end of file diff --git a/tests/color-me/manage.test.js b/tests/color-me/manage.test.js new file mode 100644 index 00000000..ad460710 --- /dev/null +++ b/tests/color-me/manage.test.js @@ -0,0 +1,174 @@ +/* + * Covers the heavy "manage" subcommand of modules/color-me/commands/color-me.js + * and its private cooldown helper (which reads the shared main-stub client). + * - cooldown still active -> editReply with the cooldown string, no role change + * - no existing record -> creates a role, persists it, adds it to the member + * - existing record + live role -> edits the role in place + * - existing record but role gone + guild at the 250 role cap -> roleLimit + * - invalid colour -> cancels before touching roles + * - Discord 30005 role-limit error on create -> roleLimit reply + * embedType is the real helper; localize/main auto-stubbed. + */ +const mainStub = require('../__stubs__/main'); +const cmd = require('../../modules/color-me/commands/color-me'); + +const strings = { + cooldown: 'cooldown %cooldown%', + updated: 'updated', + updatedNoIcon: 'updated-no-icon', + created: 'created', + createdNoIcon: 'created-no-icon', + roleLimit: 'role-limit', + invalidColor: 'invalid-color' +}; + +function setSharedModel(model) { + mainStub.client.models = {'color-me': {Role: model}}; + mainStub.client.logger = {error: jest.fn()}; + mainStub.client.guild = {features: []}; +} + +function makeInteraction({ + found = null, + color = null, + name = 'My Colour', + icon = null, + roleCacheSize = 5, + roleExists = true, + createImpl, + config = {} + } = {}) { + const createdRole = { + id: 'new-role', + name, + hexColor: '#123456', + edit: jest.fn() + }; + const liveRole = { + id: found ? found.roleID : 'live', + edit: jest.fn() + }; + const model = { + findOne: jest.fn().mockResolvedValue(found), + create: createImpl || jest.fn().mockResolvedValue(createdRole), + update: jest.fn().mockResolvedValue() + }; + setSharedModel(model); + const rolesCache = { + size: roleCacheSize, + find: () => (roleExists ? liveRole : undefined), + has: () => false + }; + return { + _model: model, + _createdRole: createdRole, + _liveRole: liveRole, + user: { + id: 'u1', + username: 'alice' + }, + member: { + roles: { + cache: {has: () => false}, + add: jest.fn().mockResolvedValue() + } + }, + guild: { + roles: { + cache: rolesCache, + resolve: () => liveRole, + create: model.create + } + }, + options: { + getAttachment: () => icon, + getString: (n) => (n === 'color' ? color : n === 'name' ? name : null) + }, + client: { + configurations: { + 'color-me': { + config: { + updateCooldown: 24, + rolePosition: null, + listRoles: false, ...config + }, + strings + } + }, + models: {'color-me': {Role: model}} + }, + editReply: jest.fn().mockResolvedValue() + }; +} + +test('replies with the cooldown message while the cooldown is active', async () => { + const recent = {timestamp: new Date()}; // just now -> 24h cooldown still active + const i = makeInteraction({ + found: { + roleID: 'r1', + timestamp: new Date() + } + }); + // shared cooldown helper reads the main-stub model.findOne + i._model.findOne.mockResolvedValue(recent); + await cmd.subcommands.manage(i); + expect(i.editReply).toHaveBeenCalledTimes(1); + expect(i.editReply.mock.calls[0][0]).toBeDefined(); + // no role created or edited + expect(i.guild.roles.create).not.toHaveBeenCalled(); +}); + +test('creates a new colour role when the user has no record', async () => { + const i = makeInteraction({found: null}); + await cmd.subcommands.manage(i); + expect(i.guild.roles.create).toHaveBeenCalled(); + expect(i._model.create).toHaveBeenCalled(); + expect(i.member.roles.add).toHaveBeenCalledWith(i._createdRole); + expect(i.editReply).toHaveBeenCalled(); +}); + +test('edits the live role in place when a record + role exist (past cooldown)', async () => { + const old = {timestamp: new Date(Date.now() - 48 * 3600000)}; // 48h ago -> allowed + const i = makeInteraction({ + found: {roleID: 'r1'}, + roleExists: true + }); + i._model.findOne.mockResolvedValueOnce(old) // cooldown lookup + .mockResolvedValueOnce({roleID: 'r1'}); // manage record lookup + await cmd.subcommands.manage(i); + expect(i._liveRole.edit).toHaveBeenCalled(); + expect(i.guild.roles.create).not.toHaveBeenCalled(); +}); + +test('reports the role limit when the stored role is gone and the guild is at 250 roles', async () => { + const old = {timestamp: new Date(Date.now() - 48 * 3600000)}; + const i = makeInteraction({ + found: {roleID: 'r1'}, + roleExists: false, + roleCacheSize: 250 + }); + i._model.findOne.mockResolvedValueOnce(old).mockResolvedValueOnce({roleID: 'r1'}); + await cmd.subcommands.manage(i); + expect(i.guild.roles.create).not.toHaveBeenCalled(); + expect(i.editReply).toHaveBeenCalled(); +}); + +test('cancels on invalid colour without creating a role', async () => { + const i = makeInteraction({ + found: null, + color: 'ZZZZZZ' + }); + await cmd.subcommands.manage(i); + expect(i.guild.roles.create).not.toHaveBeenCalled(); +}); + +test('maps a Discord 30005 error on create to the role-limit reply', async () => { + const err = Object.assign(new Error('max roles'), {code: 30005}); + const i = makeInteraction({ + found: null, + createImpl: jest.fn().mockRejectedValue(err) + }); + await cmd.subcommands.manage(i); + expect(i.editReply).toHaveBeenCalled(); + // does not rethrow for 30005 +}); \ No newline at end of file diff --git a/tests/color-me/roleModel.test.js b/tests/color-me/roleModel.test.js new file mode 100644 index 00000000..8094fd8f --- /dev/null +++ b/tests/color-me/roleModel.test.js @@ -0,0 +1,43 @@ +/* + * Covers modules/color-me/models/Role.js: the Sequelize init wiring (auto- + * increment integer PK, colorme_Role table, timestamps) and the model config + * export. super.init is patched to capture the schema. + */ +const {DataTypes} = require('sequelize'); +const Role = require('../../modules/color-me/models/Role'); + +test('exposes the expected model config', () => { + expect(Role.config).toEqual({ + name: 'Role', + module: 'color-me' + }); +}); + +test('init defines the colorme_Role table with an auto-increment PK', () => { + let captured; + const proto = Object.getPrototypeOf(Role); + const original = proto.init; + proto.init = function (attrs, opts) { + captured = { + attrs, + opts + }; + return 'ok'; + }; + try { + Role.init({}); + } finally { + proto.init = original; + } + + expect(captured.opts.tableName).toBe('colorme_Role'); + expect(captured.opts.timestamps).toBe(true); + expect(captured.attrs.id).toMatchObject({ + autoIncrement: true, + primaryKey: true, + type: DataTypes.INTEGER + }); + expect(captured.attrs.userID).toBe(DataTypes.STRING); + expect(captured.attrs.roleID).toBe(DataTypes.STRING); + expect(captured.attrs.timestamp).toBe(DataTypes.DATE); +}); \ No newline at end of file diff --git a/tests/configuration/checkType.test.js b/tests/configuration/checkType.test.js new file mode 100644 index 00000000..947b2bee --- /dev/null +++ b/tests/configuration/checkType.test.js @@ -0,0 +1,367 @@ +// Tests for configuration.checkType — the per-field type validator. +// +// checkType is async and reads the live client via require('../../main') for +// the ID-resolving branches (userID/channelID/roleID/guildID). The main stub +// is mutated in setup so those branches can be driven deterministically. +// process.exit is stubbed so the "unknown type" default branch can be asserted +// without killing the test runner. + +const {ChannelType} = require('discord.js'); +// configuration.js destructures `logger` from the main stub at require time, +// so the stub must expose a logger BEFORE configuration is first required. +const main = require('../__stubs__/main'); +main.logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +}; +main.client.logger = main.logger; +const {checkType} = require('../../src/functions/configuration'); + +const baseClient = main.client; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('checkType - integer', () => { + test('zero is always valid (short-circuit)', async () => { + await expect(checkType({type: 'integer'}, 0)).resolves.toBe(true); + await expect(checkType({type: 'integer'}, '0')).resolves.toBe(true); + }); + + test('valid positive integer', async () => { + await expect(checkType({type: 'integer'}, 42)).resolves.toBe(true); + await expect(checkType({type: 'integer'}, '42')).resolves.toBe(true); + }); + + test('non-numeric string is invalid', async () => { + await expect(checkType({type: 'integer'}, 'abc')).resolves.toBe(false); + }); + + test('rejects above maxValue', async () => { + await expect(checkType({ + type: 'integer', + maxValue: 10 + }, 11)).resolves.toBe(false); + await expect(checkType({ + type: 'integer', + maxValue: 10 + }, 10)).resolves.toBe(true); + }); + + test('rejects below minValue', async () => { + await expect(checkType({ + type: 'integer', + minValue: 5 + }, 4)).resolves.toBe(false); + await expect(checkType({ + type: 'integer', + minValue: 5 + }, 5)).resolves.toBe(true); + }); + + test('within min/max range is valid', async () => { + await expect(checkType({ + type: 'integer', + minValue: 1, + maxValue: 10 + }, 5)).resolves.toBe(true); + }); +}); + +describe('checkType - float', () => { + test('zero is valid', async () => { + await expect(checkType({type: 'float'}, 0)).resolves.toBe(true); + await expect(checkType({type: 'float'}, '0.0')).resolves.toBe(true); + }); + + test('valid float', async () => { + await expect(checkType({type: 'float'}, 1.5)).resolves.toBe(true); + }); + + test('non-numeric is invalid', async () => { + await expect(checkType({type: 'float'}, 'x')).resolves.toBe(false); + }); + + test('respects maxValue and minValue', async () => { + await expect(checkType({ + type: 'float', + maxValue: 2.5 + }, 2.6)).resolves.toBe(false); + await expect(checkType({ + type: 'float', + minValue: 1.0 + }, 0.5)).resolves.toBe(false); + await expect(checkType({ + type: 'float', + minValue: 1.0, + maxValue: 2.0 + }, 1.5)).resolves.toBe(true); + }); +}); + +describe('checkType - string-like types', () => { + test.each(['string', 'emoji', 'imgURL', 'timezone'])('%s accepts strings', async (type) => { + await expect(checkType({type}, 'hello')).resolves.toBe(true); + }); + + test.each(['string', 'emoji', 'imgURL', 'timezone'])('%s rejects non-strings', async (type) => { + await expect(checkType({type}, 123)).resolves.toBe(false); + await expect(checkType({type}, {})).resolves.toBe(false); + }); + + test('allowEmbed permits object values', async () => { + await expect(checkType({ + type: 'string', + allowEmbed: true + }, {embed: true})).resolves.toBe(true); + }); + + test('allowEmbed still accepts plain strings', async () => { + await expect(checkType({ + type: 'string', + allowEmbed: true + }, 'text')).resolves.toBe(true); + }); +}); + +describe('checkType - boolean', () => { + test('true / false are valid', async () => { + await expect(checkType({type: 'boolean'}, true)).resolves.toBe(true); + await expect(checkType({type: 'boolean'}, false)).resolves.toBe(true); + }); + + test('truthy non-boolean is invalid', async () => { + await expect(checkType({type: 'boolean'}, 'true')).resolves.toBe(false); + await expect(checkType({type: 'boolean'}, 1)).resolves.toBe(false); + }); +}); + +describe('checkType - array', () => { + test('rejects non-arrays', async () => { + await expect(checkType({ + type: 'array', + content: 'string' + }, 'not array')).resolves.toBe(false); + }); + + test('empty array is valid', async () => { + await expect(checkType({ + type: 'array', + content: 'string' + }, [])).resolves.toBe(true); + }); + + test('array of valid element types', async () => { + await expect(checkType({ + type: 'array', + content: 'string' + }, ['a', 'b'])).resolves.toBe(true); + }); + + test('array with a bad element is invalid', async () => { + await expect(checkType({ + type: 'array', + content: 'string' + }, ['a', 5])).resolves.toBe(false); + }); + + test('array of integers', async () => { + await expect(checkType({ + type: 'array', + content: 'integer' + }, [1, 2, 3])).resolves.toBe(true); + await expect(checkType({ + type: 'array', + content: 'integer' + }, [1, 'x'])).resolves.toBe(false); + }); +}); + +describe('checkType - keyed', () => { + test('rejects non-objects', async () => { + await expect(checkType({ + type: 'keyed', + content: { + key: 'string', + value: 'string' + } + }, 'str')).resolves.toBe(false); + }); + + test('valid string->string map', async () => { + await expect(checkType({ + type: 'keyed', + content: { + key: 'string', + value: 'string' + } + }, {a: 'b'})).resolves.toBe(true); + }); + + test('string->integer map with bad value', async () => { + await expect(checkType({ + type: 'keyed', + content: { + key: 'string', + value: 'integer' + } + }, {a: 'notnum'})).resolves.toBe(false); + }); + + test('empty object is valid', async () => { + await expect(checkType({ + type: 'keyed', + content: { + key: 'string', + value: 'string' + } + }, {})).resolves.toBe(true); + }); +}); + +describe('checkType - select', () => { + test('string list: value must be included', async () => { + await expect(checkType({ + type: 'select', + content: ['a', 'b', 'c'] + }, 'b')).resolves.toBe(true); + await expect(checkType({ + type: 'select', + content: ['a', 'b'] + }, 'z')).resolves.toBe(false); + }); + + test('object list: matches by .value', async () => { + const content = [{ + value: 'x', + label: 'X' + }, { + value: 'y', + label: 'Y' + }]; + await expect(checkType({ + type: 'select', + content + }, 'x')).resolves.toBeTruthy(); + await expect(checkType({ + type: 'select', + content + }, 'nope')).resolves.toBeFalsy(); + }); +}); + +describe('checkType - userID', () => { + test('valid when user resolves', async () => { + baseClient.users = {fetch: jest.fn().mockResolvedValue({id: '1'})}; + await expect(checkType({type: 'userID'}, '1')).resolves.toBe(true); + }); + + test('invalid when fetch rejects', async () => { + baseClient.users = {fetch: jest.fn().mockRejectedValue(new Error('nope'))}; + await expect(checkType({type: 'userID'}, 'bad')).resolves.toBe(false); + }); +}); + +describe('checkType - channelID', () => { + beforeEach(() => { + baseClient.guildID = 'guild-1'; + }); + + test('valid text channel on the right guild', async () => { + baseClient.channels = { + fetch: jest.fn().mockResolvedValue({ + guild: {id: 'guild-1'}, + type: ChannelType.GuildText + }) + }; + await expect(checkType({type: 'channelID'}, 'c1')).resolves.toBe(true); + }); + + test('invalid when channel not found', async () => { + baseClient.channels = {fetch: jest.fn().mockRejectedValue(new Error('x'))}; + await expect(checkType({type: 'channelID'}, 'c1')).resolves.toBe(false); + }); + + test('invalid when channel on a different guild', async () => { + baseClient.channels = { + fetch: jest.fn().mockResolvedValue({ + guild: {id: 'other-guild'}, + type: ChannelType.GuildText + }) + }; + await expect(checkType({type: 'channelID'}, 'c1')).resolves.toBe(false); + }); + + test('invalid when channel type not in allowed list', async () => { + baseClient.channels = { + fetch: jest.fn().mockResolvedValue({ + guild: {id: 'guild-1'}, + type: ChannelType.GuildVoice + }) + }; + // field.content restricts to text channels via the string alias + await expect(checkType({ + type: 'channelID', + content: ['GUILD_TEXT'] + }, 'c1')).resolves.toBe(false); + }); + + test('maps string channel-type aliases to discord enum', async () => { + baseClient.channels = { + fetch: jest.fn().mockResolvedValue({ + guild: {id: 'guild-1'}, + type: ChannelType.GuildForum + }) + }; + await expect(checkType({ + type: 'channelID', + content: ['GUILD_FORUM'] + }, 'c1')).resolves.toBe(true); + }); +}); + +describe('checkType - roleID', () => { + test('valid when role resolves', async () => { + baseClient.guildID = 'g1'; + baseClient.guilds = { + fetch: jest.fn().mockResolvedValue({ + roles: {fetch: jest.fn().mockResolvedValue({id: 'r1'})} + }) + }; + await expect(checkType({type: 'roleID'}, 'r1')).resolves.toBe(true); + }); + + test('invalid when role missing', async () => { + baseClient.guildID = 'g1'; + baseClient.guilds = { + fetch: jest.fn().mockResolvedValue({ + roles: {fetch: jest.fn().mockResolvedValue(null)} + }) + }; + await expect(checkType({type: 'roleID'}, 'r1')).resolves.toBeFalsy(); + }); +}); + +describe('checkType - guildID', () => { + test('valid when guild is in cache', async () => { + baseClient.guildID = 'g1'; + baseClient.guilds = {cache: {find: (fn) => fn({id: 'g1'}) ? {id: 'g1'} : undefined}}; + await expect(checkType({type: 'guildID'}, 'g1')).resolves.toBe(true); + }); + + test('invalid when guild not in cache', async () => { + baseClient.guildID = 'g1'; + baseClient.guilds = {cache: {find: () => undefined}}; + await expect(checkType({type: 'guildID'}, 'g1')).resolves.toBe(false); + }); +}); + +describe('checkType - unknown type', () => { + test('logs and calls process.exit(0)', async () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined); + await checkType({type: 'totally-unknown'}, 'x'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); +}); \ No newline at end of file diff --git a/tests/configuration/pure.test.js b/tests/configuration/pure.test.js new file mode 100644 index 00000000..bd829426 --- /dev/null +++ b/tests/configuration/pure.test.js @@ -0,0 +1,108 @@ +// Tests for the pure / fs-backed helpers of configuration.js: +// - isLocalizedObject: shape detector for the legacy {en, de, ...} format +// - loadConfigLocalization: reads + caches a locale JSON file from disk +// +// fs is mocked so loadConfigLocalization is exercised without touching the +// real config-localizations directory, and so caching + error fallback can be +// asserted by counting reads. + +jest.mock('fs', () => ({ + readFileSync: jest.fn() +})); + +const fs = require('fs'); +const { + isLocalizedObject, + loadConfigLocalization +} = require('../../src/functions/configuration'); + +describe('isLocalizedObject', () => { + test('true for an object with en and 2-3 letter locale keys', () => { + expect(isLocalizedObject({ + en: 'Hello', + de: 'Hallo' + })).toBe(true); + expect(isLocalizedObject({ + en: 'x', + por: 'y' + })).toBe(true); + }); + + test('false when "en" key is absent', () => { + expect(isLocalizedObject({de: 'Hallo'})).toBe(false); + }); + + test('false when a key is not a 2-3 letter code', () => { + expect(isLocalizedObject({ + en: 'x', + english: 'y' + })).toBe(false); + expect(isLocalizedObject({ + en: 'x', + e: 'y' + })).toBe(false); + expect(isLocalizedObject({ + en: 'x', + EN: 'y' + })).toBe(false); + }); + + test('false for arrays', () => { + expect(isLocalizedObject(['en'])).toBe(false); + }); + + test('false for null and undefined', () => { + expect(isLocalizedObject(null)).toBe(false); + expect(isLocalizedObject(undefined)).toBe(false); + }); + + test('false for primitives', () => { + expect(isLocalizedObject('en')).toBe(false); + expect(isLocalizedObject(42)).toBe(false); + expect(isLocalizedObject(true)).toBe(false); + }); + + test('true for an object that is only {en: ...}', () => { + expect(isLocalizedObject({en: 'only'})).toBe(true); + }); +}); + +describe('loadConfigLocalization', () => { + beforeEach(() => { + fs.readFileSync.mockReset(); + }); + + test('parses and returns the JSON content for a locale', () => { + fs.readFileSync.mockReturnValue(JSON.stringify({_core: {greeting: 'hi'}})); + const result = loadConfigLocalization('fr'); + expect(result).toEqual({_core: {greeting: 'hi'}}); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + }); + + test('caches per-locale (no second disk read)', () => { + fs.readFileSync.mockReturnValue(JSON.stringify({a: 1})); + loadConfigLocalization('it'); + loadConfigLocalization('it'); + // first call read once; second served from cache + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + }); + + test('returns empty object and caches on read error', () => { + fs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + const result = loadConfigLocalization('xx'); + expect(result).toEqual({}); + // cached empty: a repeat does not retry the failed read + loadConfigLocalization('xx'); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + }); + + test('reads from the config-localizations directory using the locale', () => { + fs.readFileSync.mockReturnValue('{}'); + loadConfigLocalization('es'); + const calledPath = fs.readFileSync.mock.calls[0][0]; + expect(calledPath).toContain('config-localizations'); + expect(calledPath).toContain('es.json'); + }); +}); \ No newline at end of file diff --git a/tests/connect-four/checkWin.test.js b/tests/connect-four/checkWin.test.js new file mode 100644 index 00000000..62838914 --- /dev/null +++ b/tests/connect-four/checkWin.test.js @@ -0,0 +1,106 @@ +/* + * Pure-logic tests for Connect Four win detection. + * + * checkWin(grid, color, position, y) is run after a circle of `color` is dropped + * into column `position`, landing at row `y`. The grid is (fieldSize-1) rows by + * fieldSize columns; empty cells are '⬜', filled cells are ':_circle:'. + * It returns the winning color (and converts the winning streak to '_square:'), + * 'tie' when the whole board is full, or undefined when nothing decisive happened. + * Covered: vertical, horizontal, both diagonals, no-win, the full-board tie, and + * that an opponent's pieces don't count toward a win. + */ +const { + checkWin, + gameMessage +} = require('../../modules/connect-four/commands/connect-four'); + +const E = '⬜'; +const circle = (color) => `:${color}_circle:`; +const square = (color) => `:${color}_square:`; + +/** Build an empty rows x cols grid. */ +function emptyGrid(rows = 6, cols = 7) { + const g = new Array(rows); + for (let i = 0; i < rows; i++) g[i] = new Array(cols).fill(E); + return g; +} + +describe('connect-four checkWin', () => { + test('detects a vertical four-in-a-column', () => { + const g = emptyGrid(); + // Stack four red circles in column 0 (rows 5..2). + g[5][0] = g[4][0] = g[3][0] = g[2][0] = circle('red'); + expect(checkWin(g, 'red', 0, 2)).toBe('red'); + // Winning cells are converted to squares. + expect(g[2][0]).toBe(square('red')); + expect(g[5][0]).toBe(square('red')); + }); + + test('detects a horizontal four-in-a-row', () => { + const g = emptyGrid(); + g[5][0] = g[5][1] = g[5][2] = g[5][3] = circle('blue'); + expect(checkWin(g, 'blue', 3, 5)).toBe('blue'); + expect(g[5][3]).toBe(square('blue')); + }); + + test('detects an ascending (/) diagonal four', () => { + const g = emptyGrid(); + g[5][0] = g[4][1] = g[3][2] = g[2][3] = circle('red'); + expect(checkWin(g, 'red', 3, 2)).toBe('red'); + }); + + test('detects a descending (\\) diagonal four', () => { + const g = emptyGrid(); + g[2][0] = g[3][1] = g[4][2] = g[5][3] = circle('red'); + expect(checkWin(g, 'red', 3, 5)).toBe('red'); + }); + + test('returns undefined when only three are connected', () => { + const g = emptyGrid(); + g[5][0] = g[5][1] = g[5][2] = circle('red'); + expect(checkWin(g, 'red', 2, 5)).toBeUndefined(); + }); + + test('an opponent piece breaking the streak prevents a win', () => { + const g = emptyGrid(); + g[5][0] = g[5][1] = circle('red'); + g[5][2] = circle('blue'); + g[5][3] = circle('red'); + expect(checkWin(g, 'red', 3, 5)).toBeUndefined(); + }); + + test('a completely full board returns a tie', () => { + // Alternate colours so no four-in-a-row exists, but board is full. + const rows = 6; + const cols = 7; + const g = emptyGrid(rows, cols); + for (let i = 0; i < rows; i++) { + for (let j = 0; j < cols; j++) { + g[i][j] = circle('red'); + } + } + // Full board: the tie branch fires before any colour win is evaluated. + expect(checkWin(g, 'red', 0, 0)).toBe('tie'); + }); + + test('does not award a win to the colour that did not connect four', () => { + const g = emptyGrid(); + g[5][0] = g[4][0] = g[3][0] = g[2][0] = circle('red'); + // Asking about blue must not report a win on red's column. + expect(checkWin(g, 'blue', 0, 2)).toBeUndefined(); + }); +}); + +describe('connect-four gameMessage', () => { + test('renders the board, the current colour and a numeric footer sized to the field', () => { + const g = emptyGrid(3, 4); // 3 rows, fieldSize 4 + const out = gameMessage(g, 4, 'red', '<@u2>', 'Alice', 'Bob'); + // The localize stub echoes the args; verify the colour circle and the + // footer emoji are bounded by the field size (4 columns -> 1️⃣..4️⃣). + expect(out).toContain('c=:red_circle:'); + expect(out).toContain('1️⃣2️⃣3️⃣4️⃣'); + expect(out).not.toContain('5️⃣'); + // The grid rows are joined into the g= argument. + expect(out).toContain('⬜⬜⬜⬜'); + }); +}); \ No newline at end of file diff --git a/tests/connect-four/run.test.js b/tests/connect-four/run.test.js new file mode 100644 index 00000000..4814014e --- /dev/null +++ b/tests/connect-four/run.test.js @@ -0,0 +1,242 @@ +/* + * Tests for the connect-four /connect-four command runner and its move handling. + * + * run(): + * - rejects challenging yourself and challenging a bot (ephemeral, no game) + * - the invite path: an expired invite edits the message, a denied invite + * updates it, and an accepted invite starts the game (renders the board + * and registers a move collector). + * The collected-move handler is captured from createMessageComponentCollector + * so we can drive turns directly: rejecting out-of-turn presses, dropping a + * circle into the lowest free row, alternating colours, and ending the game on + * a win. + */ +const cmd = require('../../modules/connect-four/commands/connect-four'); + +function makeMember(id, { + bot = false, + username = 'Bob' +} = {}) { + return { + id, + user: { + id, + bot, + username + }, + toString: () => `<@${id}>` + }; +} + +function makeInteraction({ + member, + fieldSize = 7, + authorId = 'author' + } = {}) { + const collectors = {}; + const message = { + edit: jest.fn().mockResolvedValue(), + awaitMessageComponent: jest.fn(), + createMessageComponentCollector: jest.fn(() => { + const handlers = {}; + collectors.handlers = handlers; + return { + on: (evt, fn) => { + handlers[evt] = fn; + } + }; + }) + }; + const interaction = { + user: { + id: authorId, + username: 'Alice' + }, + client: {}, + guild: {}, + options: { + getMember: jest.fn(() => member), + getInteger: jest.fn(() => fieldSize) + }, + reply: jest.fn().mockResolvedValue(message) + }; + return { + interaction, + message, + collectors + }; +} + +describe('run guards', () => { + test('rejects challenging yourself', async () => { + const {interaction} = makeInteraction({member: makeMember('author')}); + await cmd.run(interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + expect(interaction.reply.mock.calls[0][0].content).toContain('challenge-yourself'); + }); + + test('rejects challenging a bot', async () => { + const {interaction} = makeInteraction({member: makeMember('bot', {bot: true})}); + await cmd.run(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('challenge-bot'); + }); +}); + +describe('run invite resolution', () => { + test('an expired (no-response) invite edits the message to expired', async () => { + const member = makeMember('opponent'); + const { + interaction, + message + } = makeInteraction({member}); + message.awaitMessageComponent.mockResolvedValue(undefined); // collector timed out -> caught + await cmd.run(interaction); + expect(message.edit).toHaveBeenCalledWith(expect.objectContaining({components: []})); + expect(message.edit.mock.calls[0][0].content).toContain('invite-expired'); + }); + + test('a denied invite updates the message to denied', async () => { + const member = makeMember('opponent'); + const {interaction} = makeInteraction({member}); + const update = jest.fn().mockResolvedValue(); + interaction.reply.mock.results; // noop + const message = await interaction.reply.getMockImplementation?.(); + // Provide an awaitMessageComponent result with deny + interaction.reply.mockResolvedValue({ + edit: jest.fn().mockResolvedValue(), + awaitMessageComponent: jest.fn().mockResolvedValue({ + customId: 'deny-invite', + update + }), + createMessageComponentCollector: jest.fn(() => ({ + on: () => { + } + })) + }); + await cmd.run(interaction); + expect(update).toHaveBeenCalledWith(expect.objectContaining({components: []})); + expect(update.mock.calls[0][0].content).toContain('invite-denied'); + }); + + test('an accepted invite starts the game and registers a move collector', async () => { + const spy = jest.spyOn(Math, 'random').mockReturnValue(0.1); // color = blue (<=0.5) + const member = makeMember('opponent'); + const update = jest.fn().mockResolvedValue(); + const collectorOn = {}; + const collector = { + on: (evt, fn) => { + collectorOn[evt] = fn; + } + }; + const message = { + edit: jest.fn().mockResolvedValue(), + awaitMessageComponent: jest.fn().mockResolvedValue({ + customId: 'accept-invite', + update + }), + createMessageComponentCollector: jest.fn(() => collector) + }; + const interaction = { + user: { + id: 'author', + username: 'Alice' + }, + client: {}, + guild: {}, + options: { + getMember: () => member, + getInteger: () => 7 + }, + reply: jest.fn().mockResolvedValue(message) + }; + await cmd.run(interaction); + // The accepted-invite branch renders the initial board. + expect(update).toHaveBeenCalled(); + expect(update.mock.calls[0][0].content).toContain('⬜'); + expect(message.createMessageComponentCollector).toHaveBeenCalled(); + expect(typeof collectorOn.collect).toBe('function'); + spy.mockRestore(); + }); +}); + +describe('move collector', () => { + // Helper to run a game up to the collector and return the collect handler. + async function startGame({ + fieldSize = 7, + randomValue = 0.1 + } = {}) { + const spy = jest.spyOn(Math, 'random').mockReturnValue(randomValue); + const member = makeMember('opponent'); + const update = jest.fn().mockResolvedValue(); + const collectorOn = {}; + const collector = { + on: (evt, fn) => { + collectorOn[evt] = fn; + } + }; + const message = { + edit: jest.fn().mockResolvedValue(), + awaitMessageComponent: jest.fn().mockResolvedValue({ + customId: 'accept-invite', + update + }), + createMessageComponentCollector: jest.fn(() => collector) + }; + const interaction = { + user: { + id: 'author', + username: 'Alice' + }, + client: {}, + guild: {}, + options: { + getMember: () => member, + getInteger: () => fieldSize + }, + reply: jest.fn().mockResolvedValue(message) + }; + await cmd.run(interaction); + spy.mockRestore(); + // randomValue 0.1 -> color blue -> blue is interaction.user (author) + return { + collect: collectorOn.collect, + member, + interaction, + message + }; + } + + test('an out-of-turn press is rejected ephemerally', async () => { + // color blue means it's the author's turn; opponent pressing is out of turn + const { + collect, + member + } = await startGame({randomValue: 0.1}); + const i = { + user: {id: member.id}, + customId: 'c4_1', + reply: jest.fn(), + update: jest.fn() + }; + await collect(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + expect(i.update).not.toHaveBeenCalled(); + }); + + test('a valid move drops a circle and updates the board', async () => { + const {collect} = await startGame({randomValue: 0.1}); // blue = author's turn + const update = jest.fn().mockResolvedValue(); + const i = { + user: {id: 'author'}, + customId: 'c4_1', + reply: jest.fn(), + update + }; + await collect(i); + expect(update).toHaveBeenCalled(); + // After a non-winning move the board (game-message) is rendered as a string with circles. + const arg = update.mock.calls[0][0]; + const text = typeof arg === 'string' ? arg : JSON.stringify(arg); + expect(text).toContain('blue_circle'); + }); +}); \ No newline at end of file diff --git a/tests/counter/messageCreate.test.js b/tests/counter/messageCreate.test.js new file mode 100644 index 00000000..2967f2a4 --- /dev/null +++ b/tests/counter/messageCreate.test.js @@ -0,0 +1,209 @@ +/* + * Behavioural tests for the counter counting handler (events/messageCreate.js). + * + * Covers the count-sequence branches: + * - a correct next number increments currentNumber, records the counter, and + * adds the configured success reaction; + * - a wrong (non-sequential) number is rejected without advancing; + * - restartOnWrongCount resets the channel to 0 when someone posts a number + * that is not currentNumber; + * - onlyOneMessagePerUser rejects the same user counting twice in a row; + * - reaching a milestone message-count grants the milestone roles. + * + * The fparser-backed math path is left off (allowMaths:false) so the parser + * stays a synchronous integer parse and no dynamic ESM import is hit. + */ + +const handler = require('../../modules/counter/events/messageCreate'); + +function makeChannelDoc(overrides = {}) { + return { + channelID: 'count-chan', + currentNumber: 5, + lastCountedUser: 'someone-else', + userCounts: {}, + save: jest.fn().mockResolvedValue(), + ...overrides + }; +} + +function makeClient(channelDoc, { + moduleConfig = {}, + milestones = [] +} = {}) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + configurations: { + counter: { + config: { + channels: ['count-chan'], + onlyOneMessagePerUser: false, + restartOnWrongCount: false, + restartOnWrongCountMessage: 'restarted', + 'success-reaction': '✅', + 'wrong-input-message': 'wrong', + enableEasterEggs: false, + removeReactions: false, + channelDescription: '', + strikeAmount: '0', + allowCharactersInMessage: false, + allowMaths: false, + ...moduleConfig + }, + milestones + } + }, + models: { + counter: {CountChannel: {findOne: jest.fn().mockResolvedValue(channelDoc)}} + } + }; +} + +function makeMessage(content, authorId = 'counter-1') { + const replyMsg = { + delete: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue({delete: jest.fn()}) + }; + return { + content, + guild: {id: 'g1'}, + author: { + id: authorId, + bot: false, + toString: () => `<@${authorId}>` + }, + member: {roles: {add: jest.fn().mockResolvedValue()}}, + channel: { + id: 'count-chan', + setTopic: jest.fn().mockResolvedValue(), + permissionOverwrites: {create: jest.fn().mockResolvedValue()} + }, + reply: jest.fn().mockResolvedValue(replyMsg), + react: jest.fn().mockResolvedValue({remove: jest.fn()}), + delete: jest.fn().mockResolvedValue() + }; +} + +beforeEach(() => { + jest.useFakeTimers(); +}); +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +describe('counter correct next number', () => { + test('increments the count, records the user, and reacts with success', async () => { + const doc = makeChannelDoc({currentNumber: 5}); + const msg = makeMessage('6'); + await handler.run(makeClient(doc), msg); + + expect(doc.currentNumber).toBe(6); + expect(doc.lastCountedUser).toBe('counter-1'); + expect(doc.userCounts['counter-1']).toBe(1); + expect(doc.save).toHaveBeenCalled(); + expect(msg.react).toHaveBeenCalledWith('✅'); + }); +}); + +describe('counter wrong number', () => { + test('a non-sequential number does not advance the count', async () => { + const doc = makeChannelDoc({currentNumber: 5}); + const msg = makeMessage('9'); + await handler.run(makeClient(doc), msg); + + expect(doc.currentNumber).toBe(5); + expect(doc.save).not.toHaveBeenCalled(); + expect(msg.react).not.toHaveBeenCalled(); + }); + + test('non-numeric content is rejected as not-a-number', async () => { + const doc = makeChannelDoc({currentNumber: 5}); + const msg = makeMessage('banana'); + await handler.run(makeClient(doc), msg); + expect(doc.currentNumber).toBe(5); + expect(msg.react).not.toHaveBeenCalled(); + }); +}); + +describe('counter restartOnWrongCount', () => { + test('resets the channel to zero on a wrong (non-next) number', async () => { + const doc = makeChannelDoc({ + currentNumber: 5, + lastCountedUser: 'x', + userCounts: {x: 3} + }); + const msg = makeMessage('100'); + await handler.run(makeClient(doc, {moduleConfig: {restartOnWrongCount: true}}), msg); + + expect(doc.currentNumber).toBe(0); + expect(doc.lastCountedUser).toBeNull(); + expect(doc.userCounts).toEqual({}); + expect(doc.save).toHaveBeenCalled(); + expect(msg.reply).toHaveBeenCalled(); + }); +}); + +describe('counter onlyOneMessagePerUser', () => { + test('rejects the same user counting twice in a row', async () => { + const doc = makeChannelDoc({ + currentNumber: 5, + lastCountedUser: 'counter-1' + }); + const msg = makeMessage('6', 'counter-1'); + await handler.run(makeClient(doc, {moduleConfig: {onlyOneMessagePerUser: true}}), msg); + + expect(doc.currentNumber).toBe(5); + expect(doc.save).not.toHaveBeenCalled(); + expect(msg.react).not.toHaveBeenCalled(); + }); + + test('allows a different user to count next even with the restriction on', async () => { + const doc = makeChannelDoc({ + currentNumber: 5, + lastCountedUser: 'someone-else' + }); + const msg = makeMessage('6', 'counter-1'); + await handler.run(makeClient(doc, {moduleConfig: {onlyOneMessagePerUser: true}}), msg); + + expect(doc.currentNumber).toBe(6); + expect(msg.react).toHaveBeenCalledWith('✅'); + }); +}); + +describe('counter milestones', () => { + test('grants the milestone roles when the user hits the configured message count', async () => { + const doc = makeChannelDoc({ + currentNumber: 5, + userCounts: {'counter-1': 2} + }); + const msg = makeMessage('6', 'counter-1'); + const milestones = [{ + userMessageCount: '3', + giveRoles: ['role-vip'], + sendMessage: null + }]; + await handler.run(makeClient(doc, {milestones}), msg); + + // userCounts goes 2 -> 3, matching the milestone threshold. + expect(doc.userCounts['counter-1']).toBe(3); + expect(msg.member.roles.add).toHaveBeenCalledWith(['role-vip']); + }); + + test('does not grant roles when the count has not reached the threshold', async () => { + const doc = makeChannelDoc({ + currentNumber: 5, + userCounts: {'counter-1': 0} + }); + const msg = makeMessage('6', 'counter-1'); + const milestones = [{ + userMessageCount: '10', + giveRoles: ['role-vip'], + sendMessage: null + }]; + await handler.run(makeClient(doc, {milestones}), msg); + + expect(msg.member.roles.add).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/counter/messageCreateEdges.test.js b/tests/counter/messageCreateEdges.test.js new file mode 100644 index 00000000..b4c36bdf --- /dev/null +++ b/tests/counter/messageCreateEdges.test.js @@ -0,0 +1,180 @@ +/* + * Additional edge-case tests for the counter messageCreate handler not covered + * by messageCreate.test.js: + * - easter-egg reactions for special numbers (and the default success reaction + * when easter eggs are off / a non-special number is reached) + * - milestone sendMessage replies (auto-deleted) + * - channelDescription topic updates with the %x% placeholder + * - the early-return guards (no member, no guild, wrong guild, not ready) + */ +jest.mock('../../src/functions/helpers', () => ({embedType: (x) => ({content: x})})); + +const handler = require('../../modules/counter/events/messageCreate'); + +function makeChannelDoc(overrides = {}) { + return { + channelID: 'c1', + currentNumber: 5, + lastCountedUser: 'other', + userCounts: {}, + save: jest.fn().mockResolvedValue(), + ...overrides + }; +} + +function makeClient(doc, { + moduleConfig = {}, + milestones = [] +} = {}) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + configurations: { + counter: { + config: { + channels: ['c1'], + onlyOneMessagePerUser: false, + restartOnWrongCount: false, + 'success-reaction': '✅', + 'wrong-input-message': 'wrong', + enableEasterEggs: false, + removeReactions: false, + channelDescription: '', + strikeAmount: '0', + allowCharactersInMessage: false, + allowMaths: false, + ...moduleConfig + }, + milestones + } + }, + models: {counter: {CountChannel: {findOne: jest.fn().mockResolvedValue(doc)}}} + }; +} + +function makeMsg(content, authorId = 'u1') { + return { + content, + guild: {id: 'g1'}, + author: { + id: authorId, + bot: false, + toString: () => `<@${authorId}>` + }, + member: {roles: {add: jest.fn().mockResolvedValue()}}, + channel: { + id: 'c1', + setTopic: jest.fn().mockResolvedValue() + }, + reply: jest.fn().mockResolvedValue({delete: jest.fn()}), + react: jest.fn().mockResolvedValue({remove: jest.fn()}) + }; +} + +beforeEach(() => jest.useFakeTimers()); +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +describe('easter eggs', () => { + test('reacts with the 💯 egg when reaching 100', async () => { + const doc = makeChannelDoc({currentNumber: 99}); + const msg = makeMsg('100'); + await handler.run(makeClient(doc, {moduleConfig: {enableEasterEggs: true}}), msg); + expect(msg.react).toHaveBeenCalledWith('💯'); + }); + + test('reacts with two emergency eggs for 112', async () => { + const doc = makeChannelDoc({currentNumber: 111}); + const msg = makeMsg('112'); + await handler.run(makeClient(doc, {moduleConfig: {enableEasterEggs: true}}), msg); + expect(msg.react).toHaveBeenCalledWith('🚑'); + expect(msg.react).toHaveBeenCalledWith('🚒'); + }); + + test('falls back to the success reaction for a non-special number', async () => { + const doc = makeChannelDoc({currentNumber: 7}); + const msg = makeMsg('8'); + await handler.run(makeClient(doc, {moduleConfig: {enableEasterEggs: true}}), msg); + expect(msg.react).toHaveBeenCalledWith('✅'); + }); +}); + +describe('milestone messages', () => { + test('sends and auto-deletes a milestone message', async () => { + const doc = makeChannelDoc({ + currentNumber: 5, + userCounts: {u1: 2} + }); + const msg = makeMsg('6', 'u1'); + const milestones = [{ + userMessageCount: '3', + giveRoles: [], + sendMessage: 'MILESTONE' + }]; + await handler.run(makeClient(doc, {milestones}), msg); + expect(msg.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'MILESTONE'})); + }); +}); + +describe('channel description topic', () => { + test('updates the topic substituting %x% with the next number', async () => { + const doc = makeChannelDoc({currentNumber: 5}); + const msg = makeMsg('6'); + await handler.run(makeClient(doc, {moduleConfig: {channelDescription: 'Next: %x%'}}), msg); + // currentNumber becomes 6, so the topic shows currentNumber+1 = 7 + expect(msg.channel.setTopic).toHaveBeenCalledWith('Next: 7', expect.any(String)); + }); +}); + +describe('removeReactions', () => { + test('schedules removal of the success reaction', async () => { + const removeSpy = jest.fn(); + const doc = makeChannelDoc({currentNumber: 5}); + const msg = makeMsg('6'); + msg.react = jest.fn().mockResolvedValue({remove: removeSpy}); + await handler.run(makeClient(doc, {moduleConfig: {removeReactions: true}}), msg); + jest.advanceTimersByTime(5000); + await Promise.resolve(); + expect(removeSpy).toHaveBeenCalled(); + }); +}); + +describe('early-return guards', () => { + test('ignores a message without a member', async () => { + const doc = makeChannelDoc(); + const client = makeClient(doc); + const msg = makeMsg('6'); + msg.member = null; + await handler.run(client, msg); + expect(client.models.counter.CountChannel.findOne).not.toHaveBeenCalled(); + }); + + test('ignores messages from the wrong guild', async () => { + const doc = makeChannelDoc(); + const client = makeClient(doc); + const msg = makeMsg('6'); + msg.guild = {id: 'other'}; + await handler.run(client, msg); + expect(client.models.counter.CountChannel.findOne).not.toHaveBeenCalled(); + }); + + test('ignores messages before the bot is ready', async () => { + const doc = makeChannelDoc(); + const client = makeClient(doc); + client.botReadyAt = null; + const msg = makeMsg('6'); + await handler.run(client, msg); + expect(client.models.counter.CountChannel.findOne).not.toHaveBeenCalled(); + }); + + test('ignores a channel that is not configured', async () => { + const doc = makeChannelDoc(); + const client = makeClient(doc); + const msg = makeMsg('6'); + msg.channel.id = 'not-configured'; + await handler.run(client, msg); + expect(client.models.counter.CountChannel.findOne).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/counter/messageDeleteAndReady.test.js b/tests/counter/messageDeleteAndReady.test.js new file mode 100644 index 00000000..70e9fa97 --- /dev/null +++ b/tests/counter/messageDeleteAndReady.test.js @@ -0,0 +1,172 @@ +/* + * Tests for the counter messageDelete protection handler and the botReady seeder. + * + * messageDelete: only fires when protectAgainstDeletion is on, the channel is a + * configured count channel, and the deleted message was the *current* count by + * the *last* counter; it then resends a protection notice. All guards covered. + * botReady: creates a CountChannel row for each configured channel that does not + * yet have one, and leaves existing rows untouched. + */ +jest.mock('../../src/functions/helpers', () => ({embedType: (x) => ({content: x})})); + +const del = require('../../modules/counter/events/messageDelete'); +const botReady = require('../../modules/counter/events/botReady'); + +function makeClient({ + object = null, + config = {}, + channelExists = null + } = {}) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + logger: {debug: jest.fn()}, + configurations: { + counter: { + config: { + channels: ['c1'], + protectAgainstDeletion: true, + protectionMessage: 'PROTECT', + allowCharactersInMessage: false, + allowMaths: false, + ...config + } + } + }, + models: { + counter: { + CountChannel: { + findOne: jest.fn().mockResolvedValue(object !== null ? object : channelExists), + create: jest.fn().mockResolvedValue() + } + } + } + }; +} + +function makeMsg({ + content = '6', + authorId = 'u1', + channelId = 'c1' + } = {}) { + return { + content, + guild: {id: 'g1'}, + author: { + id: authorId, + bot: false, + toString: () => `<@${authorId}>` + }, + member: {}, + channel: { + id: channelId, + send: jest.fn().mockResolvedValue() + } + }; +} + +describe('messageDelete protection', () => { + test('resends a notice when the current count by the last user is deleted', async () => { + const object = { + currentNumber: 6, + lastCountedUser: 'u1' + }; + const client = makeClient({object}); + const msg = makeMsg({ + content: '6', + authorId: 'u1' + }); + await del.run(client, msg); + expect(msg.channel.send).toHaveBeenCalledWith(expect.objectContaining({content: 'PROTECT'})); + }); + + test('does nothing when protectAgainstDeletion is off', async () => { + const object = { + currentNumber: 6, + lastCountedUser: 'u1' + }; + const client = makeClient({ + object, + config: {protectAgainstDeletion: false} + }); + const msg = makeMsg(); + await del.run(client, msg); + expect(msg.channel.send).not.toHaveBeenCalled(); + }); + + test('does not fire for a deletion that was not the current number', async () => { + const object = { + currentNumber: 6, + lastCountedUser: 'u1' + }; + const client = makeClient({object}); + const msg = makeMsg({ + content: '3', + authorId: 'u1' + }); + await del.run(client, msg); + expect(msg.channel.send).not.toHaveBeenCalled(); + }); + + test('does not fire when a different user deleted their message', async () => { + const object = { + currentNumber: 6, + lastCountedUser: 'someoneElse' + }; + const client = makeClient({object}); + const msg = makeMsg({ + content: '6', + authorId: 'u1' + }); + await del.run(client, msg); + expect(msg.channel.send).not.toHaveBeenCalled(); + }); + + test('ignores deletions in non-count channels', async () => { + const client = makeClient({ + object: { + currentNumber: 6, + lastCountedUser: 'u1' + } + }); + const msg = makeMsg({channelId: 'other'}); + await del.run(client, msg); + expect(client.models.counter.CountChannel.findOne).not.toHaveBeenCalled(); + }); + + test('ignores bot authors', async () => { + const client = makeClient({ + object: { + currentNumber: 6, + lastCountedUser: 'u1' + } + }); + const msg = makeMsg(); + msg.author.bot = true; + await del.run(client, msg); + expect(client.models.counter.CountChannel.findOne).not.toHaveBeenCalled(); + }); +}); + +describe('botReady seeding', () => { + test('creates a row for a channel with no existing entry', async () => { + const client = makeClient({ + channelExists: null, + config: {channels: ['c1']} + }); + await botReady.run(client); + expect(client.models.counter.CountChannel.create).toHaveBeenCalledWith( + expect.objectContaining({ + channelID: 'c1', + currentNumber: 0, + userCounts: {} + }) + ); + }); + + test('leaves existing channels untouched', async () => { + const client = makeClient({channelExists: {channelID: 'c1'}}); + await botReady.run(client); + expect(client.models.counter.CountChannel.create).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/counter/model.test.js b/tests/counter/model.test.js new file mode 100644 index 00000000..7e3b1738 --- /dev/null +++ b/tests/counter/model.test.js @@ -0,0 +1,50 @@ +/* + * Schema test for the counter CountChannel model. Stubs Model.init to inspect + * the attribute map + options: table name, the channelID primary key, the + * userCounts JSON column with its {} default, and the module/name config. + */ +const {Model} = require('sequelize'); + +function loadModel(relPath) { + const original = Model.init; + Model.init = function (attributes, options) { + return { + attributes, + options + }; + }; + try { + const abs = require.resolve(relPath); + delete require.cache[abs]; + const mod = require(relPath); + const { + attributes, + options + } = mod.init({}); // fake sequelize + return { + mod, + attributes, + options + }; + } finally { + Model.init = original; + } +} + +test('CountChannel model', () => { + const { + mod, + attributes, + options + } = loadModel('../../modules/counter/models/CountChannel'); + expect(options.tableName).toBe('counter_countChannel'); + expect(attributes.channelID.primaryKey).toBe(true); + expect(Object.keys(attributes)).toEqual( + expect.arrayContaining(['channelID', 'currentNumber', 'lastCountedUser', 'userCounts']) + ); + expect(attributes.userCounts.defaultValue).toEqual({}); + expect(mod.config).toEqual({ + name: 'CountChannel', + module: 'counter' + }); +}); \ No newline at end of file diff --git a/tests/counter/parseMessageNumber.test.js b/tests/counter/parseMessageNumber.test.js new file mode 100644 index 00000000..1f81eff6 --- /dev/null +++ b/tests/counter/parseMessageNumber.test.js @@ -0,0 +1,55 @@ +/* + * Tests for the counter number parser (exported as countingGameParseContent). + * + * Covers: plain integers, the null result for non-numeric input and for "0" + * (since the parser treats a falsy parseInt as "not a number"), leading-number + * text, and stripping of stray characters when allowCharactersInMessage is on. + * (The allowMaths path relies on a dynamic ESM import of `fparser` that the + * Jest CJS runtime cannot satisfy, so it is intentionally not exercised here.) + */ + +const {countingGameParseContent} = require('../../modules/counter/events/messageCreate'); + +function makeClient({ + allowCharactersInMessage = false, + allowMaths = false + } = {}) { + return { + configurations: { + counter: { + config: { + allowCharactersInMessage, + allowMaths + } + } + } + }; +} + +describe('counter parseMessageNumber', () => { + test('parses a plain integer', async () => { + expect(await countingGameParseContent('42', makeClient())).toBe(42); + }); + + test('returns null for non-numeric content', async () => { + expect(await countingGameParseContent('hello', makeClient())).toBeNull(); + }); + + test('returns null for "0" (falsy parseInt is treated as not-a-number)', async () => { + expect(await countingGameParseContent('0', makeClient())).toBeNull(); + }); + + test('leading-number text still parses without the strip option', async () => { + // parseInt('7 apples') === 7 + expect(await countingGameParseContent('7 apples', makeClient())).toBe(7); + }); + + test('strips surrounding letters when allowCharactersInMessage is on', async () => { + expect(await countingGameParseContent('the answer is 15!', makeClient({allowCharactersInMessage: true}))).toBe(15); + }); + + test('keeps digits adjacent to stripped letters (no math) producing a joined number', async () => { + // Without allowMaths the stripped string '1and2' -> '12' is parsed as 12. + expect(await countingGameParseContent('1 and 2', makeClient({allowCharactersInMessage: true}))).toBe(12); + }); +}); \ No newline at end of file diff --git a/tests/discordjs-fix/blackColor.test.js b/tests/discordjs-fix/blackColor.test.js new file mode 100644 index 00000000..5016fcc7 --- /dev/null +++ b/tests/discordjs-fix/blackColor.test.js @@ -0,0 +1,30 @@ +/* + * Regression test for the 'Black' colour fix in the discord.js compat shim. + * + * The staff-management "ended" LOA/RA status DM builds its embed with + * color: 'Black'. discord.js v14's Colors enum has no `Black`, so before the + * fix setColor('Black') threw "Invalid color" at runtime and the member was + * never told their status had ended. The shim (which main.js loads as the + * production colour layer) now resolves 'Black' to pure black. + */ +require('../../src/discordjs-fix'); +const {MessageEmbed} = require('discord.js'); + +describe('discord.js-fix resolves \'Black\'', () => { + test('setColor(\'Black\') resolves to 0x000000 instead of throwing', () => { + const embed = new MessageEmbed(); + expect(() => embed.setColor('Black')).not.toThrow(); + expect(embed.data.color).toBe(0x000000); + }); + + test('resolution is case-insensitive', () => { + expect(new MessageEmbed().setColor('black').data.color).toBe(0x000000); + expect(new MessageEmbed().setColor('BLACK').data.color).toBe(0x000000); + }); + + test('the previously-working named colours still resolve', () => { + // Guard against the lookup-table edit regressing neighbouring entries. + expect(new MessageEmbed().setColor('RED').data.color).toBe(0xE74C3C); + expect(new MessageEmbed().setColor('NOT_QUITE_BLACK').data.color).toBe(0x23272A); + }); +}); \ No newline at end of file diff --git a/tests/discordjs-fix/shim.test.js b/tests/discordjs-fix/shim.test.js new file mode 100644 index 00000000..905880bf --- /dev/null +++ b/tests/discordjs-fix/shim.test.js @@ -0,0 +1,305 @@ +/* + * Tests for src/discordjs-fix.js — the compat shim that backports v13-era + * discord.js aliases and string-based enums onto v14 builders. The shim is + * loaded by jest's setupFiles, so it is already applied in-process here. + */ + +const Discord = require('discord.js'); + +describe('discordjs-fix - legacy class aliases', () => { + test('MessageEmbed aliases EmbedBuilder', () => { + expect(Discord.MessageEmbed).toBe(Discord.EmbedBuilder); + }); + + test('MessageAttachment aliases AttachmentBuilder', () => { + expect(Discord.MessageAttachment).toBe(Discord.AttachmentBuilder); + }); + + test('MessageActionRow aliases ActionRowBuilder', () => { + expect(Discord.MessageActionRow).toBe(Discord.ActionRowBuilder); + }); + + test('MessageButton aliases ButtonBuilder', () => { + expect(Discord.MessageButton).toBe(Discord.ButtonBuilder); + }); + + test('MessageSelectMenu aliases StringSelectMenuBuilder', () => { + expect(Discord.MessageSelectMenu).toBe(Discord.StringSelectMenuBuilder); + }); + + test('TextInputComponent aliases TextInputBuilder', () => { + expect(Discord.TextInputComponent).toBe(Discord.TextInputBuilder); + }); + + test('Modal aliases ModalBuilder', () => { + expect(Discord.Modal).toBe(Discord.ModalBuilder); + }); + + test('Permissions aliases PermissionsBitField', () => { + expect(Discord.Permissions).toBe(Discord.PermissionsBitField); + }); + + test('Intents.FLAGS aliases GatewayIntentBits', () => { + expect(Discord.Intents.FLAGS).toBe(Discord.GatewayIntentBits); + }); + + test('Partials alias is present', () => { + expect(Discord.Partials).toBeDefined(); + }); +}); + +describe('discordjs-fix - EmbedBuilder.addField backport', () => { + test('addField exists on the prototype', () => { + expect(typeof Discord.EmbedBuilder.prototype.addField).toBe('function'); + }); + + test('addField appends a single field', () => { + const embed = new Discord.MessageEmbed().addField('Name', 'Value'); + expect(embed.data.fields).toEqual([ + { + name: 'Name', + value: 'Value', + inline: false + } + ]); + }); + + test('addField honours the inline argument', () => { + const embed = new Discord.MessageEmbed().addField('n', 'v', true); + expect(embed.data.fields[0].inline).toBe(true); + }); + + test('addField is chainable', () => { + const embed = new Discord.MessageEmbed(); + expect(embed.addField('a', 'b')).toBe(embed); + }); + + test('addField substitutes zero-width space for empty name/value', () => { + const embed = new Discord.MessageEmbed().addField('', ''); + expect(embed.data.fields[0].name).toBe('​'); + expect(embed.data.fields[0].value).toBe('​'); + }); +}); + +describe('discordjs-fix - addFields normalization', () => { + test('replaces empty name/value with zero-width space', () => { + const embed = new Discord.EmbedBuilder().addFields({ + name: '', + value: '' + }); + expect(embed.data.fields[0].name).toBe('​'); + expect(embed.data.fields[0].value).toBe('​'); + }); + + test('preserves provided name/value', () => { + const embed = new Discord.EmbedBuilder().addFields({ + name: 'X', + value: 'Y' + }); + expect(embed.data.fields[0]).toMatchObject({ + name: 'X', + value: 'Y' + }); + }); + + test('flattens an array argument of fields', () => { + const embed = new Discord.EmbedBuilder().addFields([ + { + name: 'A', + value: '1' + }, + { + name: 'B', + value: '2' + } + ]); + expect(embed.data.fields.map(f => f.name)).toEqual(['A', 'B']); + }); + + test('accepts multiple field arguments', () => { + const embed = new Discord.EmbedBuilder().addFields( + { + name: 'A', + value: '1' + }, + { + name: 'B', + value: '2' + } + ); + expect(embed.data.fields).toHaveLength(2); + }); +}); + +describe('discordjs-fix - setDescription empty string handling', () => { + test('empty string description is dropped (treated as null)', () => { + const embed = new Discord.EmbedBuilder().setDescription(''); + expect(embed.data.description).toBeUndefined(); + }); + + test('non-empty description is preserved', () => { + const embed = new Discord.EmbedBuilder().setDescription('hi'); + expect(embed.data.description).toBe('hi'); + }); +}); + +describe('discordjs-fix - setColor resolution', () => { + test('resolves named color RED to its int', () => { + const embed = new Discord.EmbedBuilder().setColor('RED'); + expect(embed.data.color).toBe(0xE74C3C); + }); + + test('resolves named color GREEN to its int', () => { + const embed = new Discord.EmbedBuilder().setColor('GREEN'); + expect(embed.data.color).toBe(0x2ECC71); + }); + + test('resolves named color case-insensitively', () => { + const embed = new Discord.EmbedBuilder().setColor('red'); + expect(embed.data.color).toBe(0xE74C3C); + }); + + test('resolves a #-prefixed hex string', () => { + const embed = new Discord.EmbedBuilder().setColor('#ff0000'); + expect(embed.data.color).toBe(0xff0000); + }); + + test('passes numeric colors through unchanged', () => { + const embed = new Discord.EmbedBuilder().setColor(0x123456); + expect(embed.data.color).toBe(0x123456); + }); + + test('resolves BLURPLE named color', () => { + const embed = new Discord.EmbedBuilder().setColor('BLURPLE'); + expect(embed.data.color).toBe(0x5865F2); + }); +}); + +describe('discordjs-fix - ButtonBuilder.setStyle string enums', () => { + test('PRIMARY maps to ButtonStyle.Primary', () => { + const btn = new Discord.ButtonBuilder().setCustomId('x').setLabel('y').setStyle('PRIMARY'); + expect(btn.data.style).toBe(Discord.ButtonStyle.Primary); + }); + + test('SECONDARY maps to ButtonStyle.Secondary', () => { + const btn = new Discord.ButtonBuilder().setCustomId('x').setLabel('y').setStyle('SECONDARY'); + expect(btn.data.style).toBe(Discord.ButtonStyle.Secondary); + }); + + test('DANGER maps to ButtonStyle.Danger', () => { + const btn = new Discord.ButtonBuilder().setCustomId('x').setLabel('y').setStyle('DANGER'); + expect(btn.data.style).toBe(Discord.ButtonStyle.Danger); + }); + + test('LINK maps to ButtonStyle.Link', () => { + const btn = new Discord.ButtonBuilder().setURL('https://x').setLabel('y').setStyle('LINK'); + expect(btn.data.style).toBe(Discord.ButtonStyle.Link); + }); + + test('numeric style passes through unchanged', () => { + const btn = new Discord.ButtonBuilder().setCustomId('x').setLabel('y').setStyle(Discord.ButtonStyle.Success); + expect(btn.data.style).toBe(Discord.ButtonStyle.Success); + }); + + test('lowercase string style is normalized', () => { + const btn = new Discord.ButtonBuilder().setCustomId('x').setLabel('y').setStyle('primary'); + expect(btn.data.style).toBe(Discord.ButtonStyle.Primary); + }); +}); + +describe('discordjs-fix - TextInputBuilder.setStyle string enums', () => { + test('SHORT maps to TextInputStyle.Short', () => { + const ti = new Discord.TextInputBuilder().setCustomId('x').setLabel('y').setStyle('SHORT'); + expect(ti.data.style).toBe(Discord.TextInputStyle.Short); + }); + + test('PARAGRAPH maps to TextInputStyle.Paragraph', () => { + const ti = new Discord.TextInputBuilder().setCustomId('x').setLabel('y').setStyle('PARAGRAPH'); + expect(ti.data.style).toBe(Discord.TextInputStyle.Paragraph); + }); + + test('numeric style passes through unchanged', () => { + const ti = new Discord.TextInputBuilder().setCustomId('x').setLabel('y').setStyle(Discord.TextInputStyle.Paragraph); + expect(ti.data.style).toBe(Discord.TextInputStyle.Paragraph); + }); +}); + +describe('discordjs-fix - PermissionsBitField.resolve string names', () => { + test('resolves SCREAMING_SNAKE permission name', () => { + const resolved = Discord.PermissionsBitField.resolve('SEND_MESSAGES'); + expect(resolved).toBe(Discord.PermissionFlagsBits.SendMessages); + }); + + test('resolves MANAGE_CHANNELS', () => { + const resolved = Discord.PermissionsBitField.resolve('MANAGE_CHANNELS'); + expect(resolved).toBe(Discord.PermissionFlagsBits.ManageChannels); + }); + + test('resolves ADMINISTRATOR', () => { + const resolved = Discord.PermissionsBitField.resolve('ADMINISTRATOR'); + expect(resolved).toBe(Discord.PermissionFlagsBits.Administrator); + }); + + test('resolves an already-bigint permission unchanged', () => { + const bit = Discord.PermissionFlagsBits.SendMessages; + expect(Discord.PermissionsBitField.resolve(bit)).toBe(bit); + }); + + test('resolves a multi-word permission via PascalCase fallback', () => { + const resolved = Discord.PermissionsBitField.resolve('VIEW_CHANNEL'); + expect(resolved).toBe(Discord.PermissionFlagsBits.ViewChannel); + }); +}); + +describe('discordjs-fix - BaseInteraction.isSelectMenu backport', () => { + const proto = Discord.BaseInteraction.prototype; + + test('isSelectMenu is callable, alongside the modern isStringSelectMenu', () => { + // The shim guarantees legacy module code that calls + // interaction.isSelectMenu() keeps working. On discord.js v14 the method + // is native (deprecated); the shim only backports it on builds where it + // is absent (its `if (!...isSelectMenu)` guard). Either way both the + // legacy and modern predicates must be present and callable. + expect(typeof proto.isSelectMenu).toBe('function'); + expect(typeof proto.isStringSelectMenu).toBe('function'); + }); + + test('a real string-select interaction reports isStringSelectMenu() === true', () => { + // Functional check against the live discord.js predicate the shim relies on. + const fake = { + type: Discord.InteractionType.MessageComponent, + componentType: Discord.ComponentType.StringSelect + }; + expect(proto.isStringSelectMenu.call(fake)).toBe(true); + const button = { + type: Discord.InteractionType.MessageComponent, + componentType: Discord.ComponentType.Button + }; + expect(proto.isStringSelectMenu.call(button)).toBe(false); + }); +}); + +describe('discordjs-fix - Guild.me getter backport', () => { + test('Guild.prototype has a "me" accessor', () => { + const desc = Object.getOwnPropertyDescriptor(Discord.Guild.prototype, 'me'); + expect(desc).toBeDefined(); + expect(typeof desc.get).toBe('function'); + }); + + test('me getter returns members.me', () => { + const fake = {members: {me: {id: 'bot'}}}; + const getter = Object.getOwnPropertyDescriptor(Discord.Guild.prototype, 'me').get; + expect(getter.call(fake)).toEqual({id: 'bot'}); + }); +}); + +describe('discordjs-fix - module identity', () => { + test('module.exports is the same Discord namespace object', () => { + expect(require('discord.js')).toBe(Discord); + }); + + test('require cache for discord.js points at the patched namespace', () => { + const cached = require.cache[require.resolve('discord.js')].exports; + expect(cached).toBe(Discord); + }); +}); \ No newline at end of file diff --git a/tests/duel/roundResolution.test.js b/tests/duel/roundResolution.test.js new file mode 100644 index 00000000..84552011 --- /dev/null +++ b/tests/duel/roundResolution.test.js @@ -0,0 +1,60 @@ +/* + * Pure-logic tests for the duel round resolution helpers extracted from + * commands/duel.js. + * + * sortDuelAnswers(a, b) orders a pair of actions by the canonical priority + * reload < guard < gun, regardless of who chose what (this is the key used + * to look up the localized round outcome). + * isDuelGameOver(sortedAnswers) encodes the single win condition: the duel ends + * only when one player shoots (gun) while the other is reloading. + */ + +const { + sortDuelAnswers, + isDuelGameOver +} = require('../../modules/duel/commands/duel'); + +describe('sortDuelAnswers', () => { + test('orders reload before gun', () => { + expect(sortDuelAnswers('gun', 'reload')).toEqual(['reload', 'gun']); + }); + + test('orders reload before guard', () => { + expect(sortDuelAnswers('guard', 'reload')).toEqual(['reload', 'guard']); + }); + + test('orders guard before gun', () => { + expect(sortDuelAnswers('gun', 'guard')).toEqual(['guard', 'gun']); + }); + + test('is order-independent for the two inputs', () => { + expect(sortDuelAnswers('gun', 'reload')).toEqual(sortDuelAnswers('reload', 'gun')); + }); + + test('keeps identical actions as a pair', () => { + expect(sortDuelAnswers('guard', 'guard')).toEqual(['guard', 'guard']); + }); +}); + +describe('isDuelGameOver', () => { + test('ends the game on reload vs gun', () => { + expect(isDuelGameOver(sortDuelAnswers('reload', 'gun'))).toBe(true); + expect(isDuelGameOver(sortDuelAnswers('gun', 'reload'))).toBe(true); + }); + + test('does not end on gun vs guard (a blocked shot)', () => { + expect(isDuelGameOver(sortDuelAnswers('gun', 'guard'))).toBe(false); + }); + + test('does not end on mutual reload', () => { + expect(isDuelGameOver(sortDuelAnswers('reload', 'reload'))).toBe(false); + }); + + test('does not end on mutual gun', () => { + expect(isDuelGameOver(sortDuelAnswers('gun', 'gun'))).toBe(false); + }); + + test('does not end on guard vs reload', () => { + expect(isDuelGameOver(sortDuelAnswers('guard', 'reload'))).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/duel/run.test.js b/tests/duel/run.test.js new file mode 100644 index 00000000..c5113871 --- /dev/null +++ b/tests/duel/run.test.js @@ -0,0 +1,194 @@ +/* + * Tests for the duel /duel command runner (commands/duel.js) and its in-game + * button collector. + * + * run(): + * - rejects challenging yourself (ephemeral, suggests a random online member) + * - posts the invite with accept/deny buttons + * collector: + * - a non-invited user pressing accept is rejected + * - denying the invite stops the collector with a denied reason + * - accepting starts the game + * - bullet bookkeeping: reload increments bullets (capped at 5), firing a gun + * with no bullets is rejected, and a reload then gun resolves a round that + * ends the game (reload-vs-gun). + */ +const cmd = require('../../modules/duel/commands/duel'); + +// The runner arms a 120s invite-expiry setTimeout; fake timers keep that from +// leaking a live handle past the test run. +beforeEach(() => jest.useFakeTimers()); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +function makeMember(id) { + return { + id, + user: { + id, + username: `user-${id}` + }, + toString: () => `<@${id}>` + }; +} + +function makeRunContext({ + memberId = 'opp', + authorId = 'author' + } = {}) { + const member = makeMember(memberId); + const collectorHandlers = {}; + const collector = { + ended: false, + on: (evt, fn) => { + collectorHandlers[evt] = fn; + }, + stop: jest.fn() + }; + const rep = { + createMessageComponentCollector: jest.fn(() => collector), + edit: jest.fn().mockResolvedValue() + }; + const interaction = { + user: { + id: authorId, + username: 'Author', + toString: () => `<@${authorId}>` + }, + client: {}, + guild: {members: {cache: {filter: () => ({random: () => makeMember('rnd')})}}}, + options: {getMember: jest.fn(() => member)}, + reply: jest.fn().mockResolvedValue(rep) + }; + return { + interaction, + member, + rep, + collector, + collectorHandlers + }; +} + +describe('run', () => { + test('rejects challenging yourself with a random suggestion', async () => { + const {interaction} = makeRunContext({ + memberId: 'author', + authorId: 'author' + }); + await cmd.run(interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + expect(interaction.reply.mock.calls[0][0].content).toContain('self-invite-not-possible'); + }); + + test('posts the invite and registers a collector', async () => { + const { + interaction, + rep, + collectorHandlers + } = makeRunContext(); + await cmd.run(interaction); + expect(rep.createMessageComponentCollector).toHaveBeenCalled(); + expect(typeof collectorHandlers.collect).toBe('function'); + // The invite carries accept/deny buttons. + const replyArg = interaction.reply.mock.calls[0][0]; + const ids = replyArg.components[0].components.map(c => c.customId); + expect(ids).toEqual(expect.arrayContaining(['duel-accept-invite', 'duel-deny-invite'])); + }); +}); + +describe('collector invite handling', () => { + test('rejects an accept press from someone who is not the invited user', async () => { + const { + interaction, + collectorHandlers + } = makeRunContext({memberId: 'opp'}); + await cmd.run(interaction); + const i = { + user: {id: 'stranger'}, + customId: 'duel-accept-invite', + reply: jest.fn() + }; + await collectorHandlers.collect(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + }); + + test('denying the invite stops the collector', async () => { + const { + interaction, + collector, + collectorHandlers + } = makeRunContext({memberId: 'opp'}); + await cmd.run(interaction); + const i = { + user: {id: 'opp'}, + customId: 'duel-deny-invite', + reply: jest.fn(), + update: jest.fn() + }; + await collectorHandlers.collect(i); + expect(collector.stop).toHaveBeenCalled(); + }); +}); + +describe('collector gameplay', () => { + async function startedGame() { + const ctx = makeRunContext({ + memberId: 'opp', + authorId: 'author' + }); + await cmd.run(ctx.interaction); + // Accept the invite as the invited member to flip `started` true. + const accept = { + user: {id: 'opp'}, + customId: 'duel-accept-invite', + reply: jest.fn(), + update: jest.fn().mockResolvedValue() + }; + await ctx.collectorHandlers.collect(accept); + return ctx; + } + + function press(userId, action) { + return { + user: {id: userId}, + customId: `duel-${action}`, + reply: jest.fn(), + update: jest.fn().mockResolvedValue() + }; + } + + test('firing a gun with no bullets is rejected', async () => { + const {collectorHandlers} = await startedGame(); + const i = press('author', 'gun'); + await collectorHandlers.collect(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + expect(i.reply.mock.calls[0][0].content).toContain('no-bullets'); + }); + + test('a stranger cannot play once the game is running', async () => { + const {collectorHandlers} = await startedGame(); + const i = press('stranger', 'reload'); + await collectorHandlers.collect(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + expect(i.reply.mock.calls[0][0].content).toContain('not-your-game'); + }); + + test('reload then opponent gun resolves a round that ends the game', async () => { + const {collectorHandlers} = await startedGame(); + // author reloads (gains a bullet), updates board + await collectorHandlers.collect(press('author', 'reload')); + // opponent reloads to gain a bullet too + await collectorHandlers.collect(press('opp', 'reload')); + // Now both have answered the first round (reload/reload). Start round 2: + // author reloads again, opponent fires -> reload-gun ends the game. + await collectorHandlers.collect(press('author', 'reload')); + const finisher = press('opp', 'gun'); + await collectorHandlers.collect(finisher); + // The finishing update marks the game ended (content 'GGs!'). + expect(finisher.update).toHaveBeenCalled(); + const lastUpdate = finisher.update.mock.calls[finisher.update.mock.calls.length - 1][0]; + expect(lastUpdate.content).toBe('GGs!'); + }); +}); \ No newline at end of file diff --git a/tests/duration/parseDuration.test.js b/tests/duration/parseDuration.test.js new file mode 100644 index 00000000..654b7fec --- /dev/null +++ b/tests/duration/parseDuration.test.js @@ -0,0 +1,50 @@ +// parse-duration v2 ships ESM-only. The wrapper resolves it via either +// require() (Node 22.12+ / jest) or dynamic import (older Node). Tests stub +// the upstream module and trigger init() before exercising the wrapper. + +jest.mock('parse-duration', () => { + const fn = (input) => { + if (input === '5m') return 300000; + if (input === '1h') return 3600000; + if (input === '1h 30m') return 5400000; + return null; + }; + return { + __esModule: true, + default: fn + }; +}); + +const parseDuration = require('../../src/functions/parseDuration'); + +beforeAll(() => parseDuration.init()); + +describe('parseDuration wrapper', () => { + test('exposes a callable function', () => { + expect(typeof parseDuration).toBe('function'); + }); + + test('forwards to the upstream default export', () => { + expect(parseDuration('5m')).toBe(300000); + expect(parseDuration('1h')).toBe(3600000); + expect(parseDuration('1h 30m')).toBe(5400000); + }); + + test('returns null for unparseable input', () => { + expect(parseDuration('bad')).toBeNull(); + }); + + test('init() is idempotent (safe to call twice)', async () => { + await expect(parseDuration.init()).resolves.toBeUndefined(); + expect(parseDuration('5m')).toBe(300000); + }); +}); + +describe('parseDuration wrapper - error before init', () => { + test('throws a clear error when called before init() has resolved', async () => { + await jest.isolateModulesAsync(async () => { + const pd = require('../../src/functions/parseDuration'); + expect(() => pd('5m')).toThrow(/used before init/); + }); + }); +}); \ No newline at end of file diff --git a/tests/duration/parseDurationEdgeCases.test.js b/tests/duration/parseDurationEdgeCases.test.js new file mode 100644 index 00000000..8f655392 --- /dev/null +++ b/tests/duration/parseDurationEdgeCases.test.js @@ -0,0 +1,180 @@ +// Edge-case coverage for the parseDuration wrapper. parse-duration v2 is +// ESM-only and cannot be require()'d inside jest's CJS sandbox, so (like the +// existing parseDuration.test.js) we mock it. The mock below reproduces the +// real parse-duration numeric contract for the inputs under test, so these +// assertions lock in the same units / combined / whitespace / sign / format +// behaviour the production package exhibits (verified against the real module). +// +// The wrapper itself adds: lazy init(), a throw-before-init guard, and +// transparent forwarding of BOTH the input and the optional `format` argument. + +jest.mock('parse-duration', () => { + // Unit table in milliseconds, mirroring parse-duration's defaults. + const UNITS = { + ms: 1, + s: 1000, + m: 60000, + h: 3600000, + d: 86400000, + w: 604800000, + y: 31557600000 + }; + + function parse(input, format = 'ms') { + if (typeof input !== 'string') return null; + let total = 0; + let matched = false; + // value+unit pairs, tolerant of internal/surrounding whitespace + const re = /(-?\d*\.?\d+)\s*(ms|s|m|h|d|w|y)/g; + let match; + let consumed = ''; + while ((match = re.exec(input)) !== null) { + matched = true; + total += parseFloat(match[1]) * UNITS[match[2]]; + consumed += match[0]; + } + if (!matched) { + // bare number with no unit -> treated as milliseconds (e.g. "0") + const bare = input.trim(); + if (/^-?\d*\.?\d+$/.test(bare)) { + total = parseFloat(bare); + matched = true; + } + } + if (!matched) return null; + return total / UNITS[format]; + } + + return { + __esModule: true, + default: parse + }; +}); + +const parseDuration = require('../../src/functions/parseDuration'); + +beforeAll(() => parseDuration.init()); + +describe('parseDuration - single units (milliseconds)', () => { + test('milliseconds', () => { + expect(parseDuration('1ms')).toBe(1); + expect(parseDuration('250ms')).toBe(250); + }); + + test('seconds', () => { + expect(parseDuration('1s')).toBe(1000); + expect(parseDuration('30s')).toBe(30000); + }); + + test('minutes', () => { + expect(parseDuration('1m')).toBe(60000); + expect(parseDuration('5m')).toBe(300000); + }); + + test('hours', () => { + expect(parseDuration('1h')).toBe(3600000); + expect(parseDuration('2h')).toBe(7200000); + }); + + test('days', () => { + expect(parseDuration('1d')).toBe(86400000); + }); + + test('weeks', () => { + expect(parseDuration('1w')).toBe(604800000); + }); + + test('year is much larger than a day', () => { + const year = parseDuration('1y'); + const day = parseDuration('1d'); + expect(year).toBeGreaterThan(day * 364); + expect(year).toBeLessThan(day * 367); + }); +}); + +describe('parseDuration - combined / compound inputs', () => { + test('combined without spaces "1d2h"', () => { + expect(parseDuration('1d2h')).toBe(86400000 + 2 * 3600000); + }); + + test('combined with spaces "1h 30m"', () => { + expect(parseDuration('1h 30m')).toBe(5400000); + }); + + test('three-part compound "1h30m15s"', () => { + expect(parseDuration('1h30m15s')).toBe(3600000 + 30 * 60000 + 15000); + }); + + test('summing is order-independent', () => { + expect(parseDuration('30m1h')).toBe(parseDuration('1h30m')); + }); +}); + +describe('parseDuration - whitespace handling', () => { + test('leading and trailing whitespace is tolerated', () => { + expect(parseDuration(' 5m ')).toBe(300000); + }); + + test('internal whitespace between value and unit', () => { + expect(parseDuration('5 m')).toBe(300000); + }); +}); + +describe('parseDuration - decimals', () => { + test('decimal hours', () => { + expect(parseDuration('1.5h')).toBe(5400000); + }); + + test('decimal minutes', () => { + expect(parseDuration('0.5m')).toBe(30000); + }); +}); + +describe('parseDuration - zero and signs', () => { + test('plain zero returns 0', () => { + expect(parseDuration('0')).toBe(0); + }); + + test('negative durations are preserved', () => { + expect(parseDuration('-5m')).toBe(-300000); + expect(parseDuration('-1h')).toBe(-3600000); + }); +}); + +describe('parseDuration - format conversion (second argument)', () => { + test('convert minutes to seconds', () => { + expect(parseDuration('5m', 's')).toBe(300); + }); + + test('convert hours to minutes', () => { + expect(parseDuration('1h', 'm')).toBe(60); + }); + + test('convert minutes to hours yields a fraction', () => { + expect(parseDuration('30m', 'h')).toBeCloseTo(0.5, 6); + }); +}); + +describe('parseDuration - invalid input', () => { + test('empty string returns null', () => { + expect(parseDuration('')).toBeNull(); + }); + + test('non-numeric garbage returns null', () => { + expect(parseDuration('bad')).toBeNull(); + expect(parseDuration('abc')).toBeNull(); + }); + + test('pure unit without a number returns null', () => { + expect(parseDuration('m')).toBeNull(); + }); +}); + +describe('parseDuration - overflow / very large values', () => { + test('very large day counts stay finite numbers', () => { + const v = parseDuration('1000000d'); + expect(typeof v).toBe('number'); + expect(Number.isFinite(v)).toBe(true); + expect(v).toBe(1000000 * 86400000); + }); +}); \ No newline at end of file diff --git a/tests/economy-system/balanceMath.test.js b/tests/economy-system/balanceMath.test.js new file mode 100644 index 00000000..3b83ee4b --- /dev/null +++ b/tests/economy-system/balanceMath.test.js @@ -0,0 +1,182 @@ +/* + * Tests for the economy-system money math: + * editBalance - add / remove (clamped at 0) / set, and an invalid action. + * editBank - deposit (capped at the wallet balance) and + * withdraw (capped at the bank, clamped at 0), which also move + * money in/out of the wallet via editBalance. + * topTen - sorts users by (balance + bank) desc and caps the list at 10. + * + * The Balance model and leaderboard side effects are stubbed. leaderboardChannel + * is left empty so leaderboard() short-circuits and never touches Discord. + */ + +const eco = require('../../modules/economy-system/economy-system'); + +/** A fake sequelize-ish balance row with a tracked save(). */ +function makeRow(id, balance, bank) { + return { + id, + balance, + bank, + save: jest.fn().mockResolvedValue() + }; +} + +function makeClient(rows) { + const byId = new Map(rows.map((r) => [r.id, r])); + return { + logger: { + error: jest.fn(), + fatal: jest.fn(), + info: jest.fn() + }, + configurations: { + 'economy-system': { + config: { + leaderboardChannel: '', + currencySymbol: '$', + startMoney: 0 + } + } + }, + models: { + 'economy-system': { + Balance: { + findOne: jest.fn(({where}) => Promise.resolve(byId.get(where.id) || null)), + create: jest.fn().mockResolvedValue(), + findAll: jest.fn().mockResolvedValue(rows) + } + } + } + }; +} + +describe('editBalance', () => { + test('add increases the wallet', async () => { + const row = makeRow('u1', 100, 0); + await eco.editBalance(makeClient([row]), 'u1', 'add', 50); + expect(row.balance).toBe(150); + expect(row.save).toHaveBeenCalled(); + }); + + test('remove decreases the wallet', async () => { + const row = makeRow('u1', 100, 0); + await eco.editBalance(makeClient([row]), 'u1', 'remove', 30); + expect(row.balance).toBe(70); + }); + + test('remove clamps the wallet at zero (never negative)', async () => { + const row = makeRow('u1', 20, 0); + await eco.editBalance(makeClient([row]), 'u1', 'remove', 100); + expect(row.balance).toBe(0); + }); + + test('set overwrites the wallet to an exact value', async () => { + const row = makeRow('u1', 100, 0); + await eco.editBalance(makeClient([row]), 'u1', 'set', 7); + expect(row.balance).toBe(7); + }); + + test('an unknown action logs an error and does not save', async () => { + const row = makeRow('u1', 100, 0); + const client = makeClient([row]); + await eco.editBalance(client, 'u1', 'bogus', 5); + expect(client.logger.error).toHaveBeenCalled(); + expect(row.balance).toBe(100); + expect(row.save).not.toHaveBeenCalled(); + }); + + test('coerces string inputs numerically rather than concatenating', async () => { + const row = makeRow('u1', '100', 0); + await eco.editBalance(makeClient([row]), 'u1', 'add', '5'); + expect(row.balance).toBe(105); + }); +}); + +describe('editBank', () => { + test('deposit moves money from wallet into the bank', async () => { + const row = makeRow('u1', 100, 0); + await eco.editBank(makeClient([row]), 'u1', 'deposit', 40); + expect(row.bank).toBe(40); + // editBalance('remove') was invoked, draining the wallet. + expect(row.balance).toBe(60); + }); + + test('deposit of more than the wallet only banks the available balance', async () => { + const row = makeRow('u1', 30, 0); + await eco.editBank(makeClient([row]), 'u1', 'deposit', 1000); + expect(row.bank).toBe(30); + expect(row.balance).toBe(0); + }); + + test('withdraw moves money from the bank back into the wallet', async () => { + const row = makeRow('u1', 0, 50); + await eco.editBank(makeClient([row]), 'u1', 'withdraw', 20); + expect(row.bank).toBe(30); + expect(row.balance).toBe(20); + }); + + test('withdraw of more than the bank only withdraws what is there', async () => { + const row = makeRow('u1', 0, 50); + await eco.editBank(makeClient([row]), 'u1', 'withdraw', 999); + expect(row.bank).toBe(0); + expect(row.balance).toBe(50); + }); + + test('an unknown action logs an error', async () => { + const row = makeRow('u1', 10, 10); + const client = makeClient([row]); + await eco.editBank(client, 'u1', 'bogus', 5); + expect(client.logger.error).toHaveBeenCalled(); + }); +}); + +describe('topTen', () => { + const client = makeClient([]); + + test('sorts by combined wallet + bank, richest first', async () => { + const rows = [ + { + dataValues: { + id: 'a', + balance: 10, + bank: 0 + } + }, + { + dataValues: { + id: 'b', + balance: 100, + bank: 100 + } + }, + { + dataValues: { + id: 'c', + balance: 0, + bank: 50 + } + } + ]; + const out = await eco.topTen(rows, client); + const order = out.trim().split('\n').map((l) => l.match(/<@!(\w+)>/)[1]); + expect(order).toEqual(['b', 'c', 'a']); + expect(out).toContain('200 $'); + }); + + test('caps the leaderboard at ten entries', async () => { + const rows = Array.from({length: 15}, (_, i) => ({ + dataValues: { + id: `u${i}`, + balance: i, + bank: 0 + } + })); + const out = await eco.topTen(rows, client); + expect(out.trim().split('\n')).toHaveLength(10); + }); + + test('returns undefined for an empty user set', async () => { + expect(await eco.topTen([], client)).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/tests/economy-system/commands.test.js b/tests/economy-system/commands.test.js new file mode 100644 index 00000000..b22e56a0 --- /dev/null +++ b/tests/economy-system/commands.test.js @@ -0,0 +1,626 @@ +/* + * Behavioural tests for the economy-system /economy command subcommands + * (modules/economy-system/commands/economy-system.js). + * + * The economy-system core (editBalance/editBank/createLeaderboard) and helpers + * are mocked so we assert the command's own decision logic: + * - cooldown gating on work/crime/rob/daily/weekly (the shared cooldown() + * helper creates a row when none exists, blocks while still inside the + * window, and refreshes the timestamp once it has elapsed) + * - work credits a random amount within the configured bounds + * - crime's win/lose coin flip and the "no wallet -> drain bank" fallback + * - rob percentage maths, the maxRobAmount cap, and the "victim not found" guard + * - admin add/remove/set permission gating + the self-abuse guard + * - balance lookups, deposit/withdraw 'all' resolution and NaN handling + * - msg_drop enable/disable toggling and destroy's permission gate + */ + +const mockEditBalance = jest.fn().mockResolvedValue(); +const mockEditBank = jest.fn().mockResolvedValue(); +const mockCreateLeaderboard = jest.fn().mockResolvedValue(); +jest.mock('../../modules/economy-system/economy-system', () => ({ + editBalance: (...a) => mockEditBalance(...a), + editBank: (...a) => mockEditBank(...a), + createLeaderboard: (...a) => mockCreateLeaderboard(...a) +})); + +const mockRandomInt = jest.fn(); +const mockRandomElement = jest.fn((arr) => arr[0]); +jest.mock('../../src/functions/helpers', () => ({ + embedType: (input, args, opts) => ({ + input, + args, + opts + }), + randomIntFromInterval: (...a) => mockRandomInt(...a), + randomElementFromArray: (...a) => mockRandomElement(...a), + formatDiscordUserName: (u) => (u && u.tag) || 'user' +})); + +const cmd = require('../../modules/economy-system/commands/economy-system'); + +function makeModels({ + cooldownRow = null, + balanceRow = undefined, + dropRow = null + } = {}) { + const cooldown = { + findOne: jest.fn().mockResolvedValue(cooldownRow), + create: jest.fn().mockResolvedValue(), + findAll: jest.fn().mockResolvedValue([]) + }; + const Balance = { + findOne: jest.fn().mockResolvedValue(balanceRow === undefined ? null : balanceRow), + findAll: jest.fn().mockResolvedValue([]) + }; + const dropMsg = { + findOne: jest.fn().mockResolvedValue(dropRow), + create: jest.fn().mockResolvedValue(), + findAll: jest.fn().mockResolvedValue([]) + }; + const Shop = {findAll: jest.fn().mockResolvedValue([])}; + return { + cooldown, + Balance, + dropMsg, + Shop + }; +} + +function makeInteraction({ + userId = 'me', + config = {}, + strings = {}, + models = makeModels(), + options = {}, + botOperators = [], + logChannel = null + } = {}) { + const baseConfig = { + publicCommandReplies: false, + currencySymbol: '$', + workCooldown: 5, + crimeCooldown: 5, + robCooldown: 5, + minWorkMoney: 10, + maxWorkMoney: 50, + minCrimeMoney: 10, + maxCrimeMoney: 50, + robPercent: 50, + maxRobAmount: 1000, + dailyReward: 100, + weeklyReward: 500, + admins: [], + selfBalance: false, + ...config + }; + const baseStrings = { + cooldown: 'COOLDOWN', + workSuccess: ['WORK'], + crimeSuccess: ['CRIME_WIN'], + crimeFail: ['CRIME_LOSE'], + robSuccess: 'ROB', + userNotFound: 'NOT_FOUND', + dailyReward: 'DAILY', + weeklyReward: 'WEEKLY', + balanceReply: 'BAL', + depositMsg: 'DEP', + withdrawMsg: 'WD', + NaN: 'NAN', + msgDropAlreadyEnabled: 'A_EN', + msgDropEnabled: 'EN', + msgDropAlreadyDisabled: 'A_DIS', + msgDropDisabled: 'DIS', + ...strings + }; + const interaction = { + user: { + id: userId, + tag: 'Me#1', + toString: () => `<@${userId}>` + }, + reply: jest.fn().mockResolvedValue(), + options: { + getUser: jest.fn((name) => options[`user_${name}`] ?? options.user ?? null), + getInteger: jest.fn((name) => options[name] ?? null), + getBoolean: jest.fn((name) => options[name] ?? null), + get: jest.fn((name) => (name in options ? {value: options[name]} : undefined)) + }, + client: { + config: {botOperators}, + strings: {not_enough_permissions: 'NO_PERMS'}, + logChannel, + logger: { + info: jest.fn(), + error: jest.fn() + }, + configurations: { + 'economy-system': { + config: baseConfig, + strings: baseStrings + } + }, + models: {'economy-system': models} + } + }; + // beforeSubcommand wires interaction.str / interaction.config + return interaction; +} + +beforeEach(() => { + mockEditBalance.mockClear(); + mockEditBank.mockClear(); + mockCreateLeaderboard.mockClear(); + mockRandomInt.mockReset().mockReturnValue(25); + mockRandomElement.mockClear().mockImplementation((arr) => arr[0]); +}); + +async function withBefore(interaction, sub) { + await cmd.beforeSubcommand(interaction); + return sub(interaction); +} + +describe('beforeSubcommand', () => { + test('attaches the module strings and config onto the interaction', async () => { + const interaction = makeInteraction(); + await cmd.beforeSubcommand(interaction); + expect(interaction.str).toBe(interaction.client.configurations['economy-system'].strings); + expect(interaction.config).toBe(interaction.client.configurations['economy-system'].config); + }); +}); + +describe('work + cooldown helper', () => { + test('creates a cooldown row and credits the wallet on first use', async () => { + const models = makeModels({cooldownRow: null}); + mockRandomInt.mockReturnValue(33); + const interaction = makeInteraction({models}); + await withBefore(interaction, cmd.subcommands.work); + + expect(models.cooldown.create).toHaveBeenCalledWith(expect.objectContaining({ + command: 'work', + userId: 'me' + })); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'me', 'add', 33); + expect(mockCreateLeaderboard).toHaveBeenCalled(); + // The reply uses the (mocked) success string, not the cooldown string. + expect(interaction.reply.mock.calls[0][0].input).toBe('WORK'); + }); + + test('blocks work while the cooldown window is still active', async () => { + const cooldownRow = { + timestamp: new Date(Date.now()), + save: jest.fn() + }; + const interaction = makeInteraction({models: makeModels({cooldownRow})}); + await withBefore(interaction, cmd.subcommands.work); + + expect(mockEditBalance).not.toHaveBeenCalled(); + expect(interaction.reply.mock.calls[0][0].input).toBe('COOLDOWN'); + expect(cooldownRow.save).not.toHaveBeenCalled(); + }); + + test('refreshes the timestamp and proceeds once the window has elapsed', async () => { + const cooldownRow = { + timestamp: new Date(Date.now() - 10 * 60000), + save: jest.fn().mockResolvedValue() + }; + const interaction = makeInteraction({models: makeModels({cooldownRow})}); + await withBefore(interaction, cmd.subcommands.work); + + expect(cooldownRow.save).toHaveBeenCalled(); + expect(mockEditBalance).toHaveBeenCalled(); + }); + + test('rolls the earnings between min and max as passed to randomIntFromInterval', async () => { + const interaction = makeInteraction({ + config: { + minWorkMoney: 5, + maxWorkMoney: 9 + } + }); + await withBefore(interaction, cmd.subcommands.work); + // Source passes (min, max) in order: randomIntFromInterval(minWorkMoney, maxWorkMoney). + expect(mockRandomInt).toHaveBeenCalledWith(5, 9); + }); +}); + +describe('crime', () => { + test('a winning flip credits a random amount', async () => { + const spy = jest.spyOn(Math, 'random').mockReturnValue(0.99); // floor(0.99*2)=1 -> success branch + mockRandomInt.mockReturnValue(40); + const interaction = makeInteraction(); + await withBefore(interaction, cmd.subcommands.crime); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'me', 'add', 40); + expect(interaction.reply.mock.calls[0][0].input).toBe('CRIME_WIN'); + spy.mockRestore(); + }); + + test('a losing flip removes half the wallet balance', async () => { + const spy = jest.spyOn(Math, 'random').mockReturnValue(0.1); // floor(0.1*2)=0 -> fail branch + const balanceRow = {balance: 80}; + const interaction = makeInteraction({models: makeModels({balanceRow})}); + await withBefore(interaction, cmd.subcommands.crime); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'me', 'remove', 40); + expect(interaction.reply.mock.calls[0][0].input).toBe('CRIME_LOSE'); + spy.mockRestore(); + }); + + test('a losing flip with an empty wallet drains the bank by maxCrimeMoney', async () => { + const spy = jest.spyOn(Math, 'random').mockReturnValue(0); // fail branch + const balanceRow = {balance: 0}; + const interaction = makeInteraction({ + models: makeModels({balanceRow}), + config: {maxCrimeMoney: 200} + }); + await withBefore(interaction, cmd.subcommands.crime); + expect(mockEditBank).toHaveBeenCalledWith(interaction.client, 'me', 'remove', 200); + spy.mockRestore(); + }); + + test('respects the crime cooldown', async () => { + const cooldownRow = { + timestamp: new Date(), + save: jest.fn() + }; + const interaction = makeInteraction({models: makeModels({cooldownRow})}); + await withBefore(interaction, cmd.subcommands.crime); + expect(interaction.reply.mock.calls[0][0].input).toBe('COOLDOWN'); + }); +}); + +describe('rob', () => { + test('rejects when the victim has no balance row', async () => { + const models = makeModels(); + models.Balance.findOne.mockResolvedValue(null); + const interaction = makeInteraction({ + models, + options: { + user: { + id: 'victim', + tag: 'V#1' + } + } + }); + await withBefore(interaction, cmd.subcommands.rob); + expect(interaction.reply.mock.calls[0][0].input).toBe('NOT_FOUND'); + expect(mockEditBalance).not.toHaveBeenCalled(); + }); + + test('transfers robPercent of the victim balance to the robber', async () => { + const models = makeModels(); + models.Balance.findOne.mockResolvedValue({balance: 200}); + const interaction = makeInteraction({ + models, + options: { + user: { + id: 'victim', + tag: 'V#1' + } + }, + config: { + robPercent: 25, + maxRobAmount: 1000 + } + }); + await withBefore(interaction, cmd.subcommands.rob); + // 25% of 200 = 50 + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'me', 'add', 50); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'victim', 'remove', 50); + }); + + test('caps the stolen amount at maxRobAmount', async () => { + const models = makeModels(); + models.Balance.findOne.mockResolvedValue({balance: 10000}); + const interaction = makeInteraction({ + models, + options: { + user: { + id: 'victim', + tag: 'V#1' + } + }, + config: { + robPercent: 100, + maxRobAmount: 300 + } + }); + await withBefore(interaction, cmd.subcommands.rob); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'me', 'add', 300); + }); +}); + +describe('daily and weekly', () => { + test('daily adds the configured reward and uses a 24h cooldown', async () => { + const models = makeModels({cooldownRow: null}); + const interaction = makeInteraction({ + models, + config: {dailyReward: 100} + }); + await withBefore(interaction, cmd.subcommands.daily); + expect(models.cooldown.create).toHaveBeenCalledWith(expect.objectContaining({command: 'daily'})); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'me', 'add', 100); + }); + + test('weekly adds the configured weekly reward', async () => { + const interaction = makeInteraction({config: {weeklyReward: 700}}); + await withBefore(interaction, cmd.subcommands.weekly); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'me', 'add', 700); + }); + + test('daily is blocked while on cooldown', async () => { + const cooldownRow = { + timestamp: new Date(), + save: jest.fn() + }; + const interaction = makeInteraction({models: makeModels({cooldownRow})}); + await withBefore(interaction, cmd.subcommands.daily); + expect(mockEditBalance).not.toHaveBeenCalled(); + }); +}); + +describe('balance', () => { + test('replies with the requested user balance breakdown', async () => { + const models = makeModels(); + models.Balance.findOne.mockResolvedValue({ + balance: 30, + bank: 70 + }); + const interaction = makeInteraction({ + models, + options: { + user: { + id: 'other', + tag: 'O#1' + } + } + }); + await withBefore(interaction, cmd.subcommands.balance); + const args = interaction.reply.mock.calls[0][0].args; + expect(args['%balance%']).toBe('30 $'); + expect(args['%bank%']).toBe('70 $'); + expect(args['%total%']).toBe('100 $'); + }); + + test('defaults to the caller when no user option is given', async () => { + const models = makeModels(); + models.Balance.findOne.mockResolvedValue({ + balance: 1, + bank: 2 + }); + const interaction = makeInteraction({ + models, + options: {} + }); + await withBefore(interaction, cmd.subcommands.balance); + expect(models.Balance.findOne).toHaveBeenCalledWith({where: {id: 'me'}}); + }); + + test('replies userNotFound when there is no balance row', async () => { + const models = makeModels(); + models.Balance.findOne.mockResolvedValue(null); + const interaction = makeInteraction({ + models, + options: { + user: { + id: 'ghost', + tag: 'G#1' + } + } + }); + await withBefore(interaction, cmd.subcommands.balance); + expect(interaction.reply.mock.calls[0][0].input).toBe('NOT_FOUND'); + }); +}); + +describe('deposit and withdraw', () => { + test('deposit "all" resolves to the wallet balance', async () => { + const models = makeModels(); + models.Balance.findOne.mockResolvedValue({ + balance: 60, + bank: 0 + }); + const interaction = makeInteraction({ + models, + options: {amount: 'all'} + }); + await withBefore(interaction, cmd.subcommands.deposit); + expect(mockEditBank).toHaveBeenCalledWith(interaction.client, 'me', 'deposit', 60); + }); + + test('deposit rejects a non-numeric amount', async () => { + const models = makeModels(); + models.Balance.findOne.mockResolvedValue({ + balance: 60, + bank: 0 + }); + const interaction = makeInteraction({ + models, + options: {amount: 'banana'} + }); + await withBefore(interaction, cmd.subcommands.deposit); + expect(interaction.reply.mock.calls[0][0].input).toBe('NAN'); + expect(mockEditBank).not.toHaveBeenCalled(); + }); + + test('withdraw "all" resolves to the bank balance', async () => { + const models = makeModels(); + models.Balance.findOne.mockResolvedValue({ + balance: 0, + bank: 40 + }); + const interaction = makeInteraction({ + models, + options: {amount: 'all'} + }); + await withBefore(interaction, cmd.subcommands.withdraw); + expect(mockEditBank).toHaveBeenCalledWith(interaction.client, 'me', 'withdraw', 40); + }); +}); + +describe('admin add/remove/set permission gating', () => { + test('add is denied for non-admins', async () => { + const interaction = makeInteraction({ + options: { + user: { + id: 'target', + tag: 'T#1' + }, + amount: 50 + } + }); + await withBefore(interaction, cmd.subcommands.add); + expect(interaction.reply.mock.calls[0][0].input).toBe('NO_PERMS'); + expect(mockEditBalance).not.toHaveBeenCalled(); + }); + + test('add works for a configured admin', async () => { + const interaction = makeInteraction({ + userId: 'admin', + config: {admins: ['admin']}, + options: { + user: { + id: 'target', + tag: 'T#1' + }, + amount: 50 + } + }); + await withBefore(interaction, cmd.subcommands.add); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'target', 'add', 50); + }); + + test('a bot operator can use admin commands even when not in admins', async () => { + const interaction = makeInteraction({ + userId: 'op', + botOperators: ['op'], + options: { + user: { + id: 'target', + tag: 'T#1' + }, + balance: 99 + } + }); + await withBefore(interaction, cmd.subcommands.set); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'target', 'set', 99); + }); + + test('self-targeting is blocked unless selfBalance is enabled', async () => { + const interaction = makeInteraction({ + userId: 'admin', + config: { + admins: ['admin'], + selfBalance: false + }, + options: { + user: { + id: 'admin', + tag: 'A#1' + }, + amount: 10 + } + }); + await withBefore(interaction, cmd.subcommands.add); + expect(mockEditBalance).not.toHaveBeenCalled(); + expect(interaction.reply.mock.calls[0][0].content).toContain('admin-self-abuse-answer'); + }); + + test('remove subtracts the amount for an admin', async () => { + const interaction = makeInteraction({ + userId: 'admin', + config: {admins: ['admin']}, + options: { + user: { + id: 'target', + tag: 'T#1' + }, + amount: 20 + } + }); + await withBefore(interaction, cmd.subcommands.remove); + expect(mockEditBalance).toHaveBeenCalledWith(interaction.client, 'target', 'remove', 20); + }); +}); + +describe('msg_drop toggles', () => { + test('enable destroys an existing opt-out row (re-enabling drops)', async () => { + const dropRow = {destroy: jest.fn().mockResolvedValue()}; + const interaction = makeInteraction({models: makeModels({dropRow})}); + await withBefore(interaction, cmd.subcommands.msg_drop_msg.enable); + expect(dropRow.destroy).toHaveBeenCalled(); + expect(interaction.reply.mock.calls[0][0].input).toBe('EN'); + }); + + test('enable reports "already enabled" when there is no opt-out row', async () => { + const interaction = makeInteraction({models: makeModels({dropRow: null})}); + await withBefore(interaction, cmd.subcommands.msg_drop_msg.enable); + expect(interaction.reply.mock.calls[0][0].input).toBe('A_EN'); + }); + + test('disable creates an opt-out row when none exists', async () => { + const models = makeModels({dropRow: null}); + const interaction = makeInteraction({models}); + await withBefore(interaction, cmd.subcommands.msg_drop_msg.disable); + expect(models.dropMsg.create).toHaveBeenCalledWith({id: 'me'}); + expect(interaction.reply.mock.calls[0][0].input).toBe('DIS'); + }); + + test('disable reports "already disabled" when a row exists', async () => { + const interaction = makeInteraction({models: makeModels({dropRow: {}})}); + await withBefore(interaction, cmd.subcommands.msg_drop_msg.disable); + expect(interaction.reply.mock.calls[0][0].input).toBe('A_DIS'); + }); +}); + +describe('destroy', () => { + test('is denied for non-admins', async () => { + const interaction = makeInteraction({options: {confirm: true}}); + await withBefore(interaction, cmd.subcommands.destroy); + expect(interaction.reply.mock.calls[0][0].input).toBe('NO_PERMS'); + }); + + test('aborts without the confirm flag', async () => { + const interaction = makeInteraction({ + userId: 'admin', + config: {admins: ['admin']}, + options: {confirm: false} + }); + await withBefore(interaction, cmd.subcommands.destroy); + expect(interaction.reply.mock.calls[0][0].content).toContain('destroy-cancel-reply'); + }); + + test('with confirm wipes every model collection', async () => { + const models = makeModels(); + const rows = (n) => Array.from({length: n}, () => ({destroy: jest.fn().mockResolvedValue()})); + models.cooldown.findAll.mockResolvedValue(rows(2)); + models.dropMsg.findAll.mockResolvedValue(rows(1)); + models.Shop.findAll.mockResolvedValue(rows(3)); + models.Balance.findAll.mockResolvedValue(rows(2)); + const interaction = makeInteraction({ + userId: 'admin', + config: {admins: ['admin']}, + models, + options: {confirm: true} + }); + await withBefore(interaction, cmd.subcommands.destroy); + expect(interaction.reply.mock.calls[0][0].content).toContain('destroy-reply'); + expect(models.cooldown.findAll).toHaveBeenCalled(); + expect(models.Balance.findAll).toHaveBeenCalled(); + }); +}); + +describe('config.options builder', () => { + test('omits cheat subcommands when allowCheats is off', () => { + const client = {configurations: {'economy-system': {config: {allowCheats: false}}}}; + const names = cmd.config.options(client).map((o) => o.name); + expect(names).toContain('work'); + expect(names).not.toContain('add'); + expect(names).not.toContain('destroy'); + }); + + test('includes add/remove/set/destroy when allowCheats is on', () => { + const client = {configurations: {'economy-system': {config: {allowCheats: true}}}}; + const names = cmd.config.options(client).map((o) => o.name); + expect(names).toEqual(expect.arrayContaining(['add', 'remove', 'set', 'destroy'])); + }); +}); \ No newline at end of file diff --git a/tests/economy-system/coreFunctions.test.js b/tests/economy-system/coreFunctions.test.js new file mode 100644 index 00000000..2bfeaee3 --- /dev/null +++ b/tests/economy-system/coreFunctions.test.js @@ -0,0 +1,306 @@ +/* + * Tests for the economy-system core (modules/economy-system/economy-system.js) + * beyond the balance maths already covered by balanceMath.test.js: + * - getUser/createUser: lazy creation of a Balance row seeded with startMoney + * - buyShopItem: not-found / ambiguous-match / already-owned / too-poor guards + * and the happy path (grant role, charge price) + * - createShopItemAPI / deleteShopItemAPI: duplicate + missing-item handling + * - createShopMsg: renders the item string + a select menu only when items exist + * + * The Discord/DB surface is mocked; leaderboardChannel/shopChannel are left + * empty so the side-effecting leaderboard()/shopMsg() short-circuit. + */ + +const eco = require('../../modules/economy-system/economy-system'); + +function makeClient({ + balanceRows = [], + shopItems = [], + shopFindOne = undefined + } = {}) { + const byId = new Map(balanceRows.map((r) => [r.id, r])); + return { + logger: { + info: jest.fn(), + error: jest.fn(), + fatal: jest.fn() + }, + logChannel: null, + configurations: { + 'economy-system': { + config: { + leaderboardChannel: '', + shopChannel: '', + currencySymbol: '$', + startMoney: 250 + }, + strings: { + itemCreate: 'CREATE', + itemDuplicate: 'DUP', + notFound: 'NF', + multipleMatches: 'MULTI', + rebuyItem: 'REBUY', + notEnoughMoney: 'POOR', + buyMsg: 'BUY', + itemString: '%itemName% - %price%', + shopMsg: 'SHOP %shopItems%' + } + } + }, + models: { + 'economy-system': { + Balance: { + findOne: jest.fn(({where}) => Promise.resolve(byId.get(where.id) || null)), + create: jest.fn((row) => { + byId.set(row.id, { + ...row, + save: jest.fn().mockResolvedValue() + }); + return Promise.resolve(); + }), + findAll: jest.fn().mockResolvedValue(balanceRows) + }, + Shop: { + findOne: jest.fn().mockResolvedValue(shopFindOne === undefined ? null : shopFindOne), + findAll: jest.fn().mockResolvedValue(shopItems), + create: jest.fn().mockResolvedValue() + } + } + } + }; +} + +describe('getUser / createUser', () => { + test('returns an existing balance row without creating one', async () => { + const row = { + id: 'u1', + balance: 5, + bank: 0 + }; + const client = makeClient({balanceRows: [row]}); + const got = await eco.getUser(client, 'u1'); + expect(got).toBe(row); + expect(client.models['economy-system'].Balance.create).not.toHaveBeenCalled(); + }); + + test('creates a fresh row seeded with startMoney in the bank when missing', async () => { + const client = makeClient({balanceRows: []}); + await eco.getUser(client, 'new'); + expect(client.models['economy-system'].Balance.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'new', + balance: 0, + bank: 250 + }) + ); + }); +}); + +function makeShopInteraction({ + item, + balance, + memberHasRole = false + } = {}) { + const editReply = jest.fn().mockResolvedValue(); + return { + editReply, + user: { + id: 'buyer', + tag: 'Buyer#1' + }, + member: { + roles: { + cache: {has: () => memberHasRole}, + add: jest.fn().mockResolvedValue() + } + }, + client: { + logger: {info: jest.fn()}, + logChannel: null, + configurations: { + 'economy-system': { + config: { + leaderboardChannel: '', + shopChannel: '', + currencySymbol: '$', + startMoney: 0 + }, + strings: { + notFound: 'NF', + multipleMatches: 'MULTI', + rebuyItem: 'REBUY', + notEnoughMoney: 'POOR', + buyMsg: 'BUY', + itemString: 'x', + shopMsg: 'SHOP %shopItems%' + } + } + }, + models: { + 'economy-system': { + Shop: {findAll: jest.fn().mockResolvedValue(item ? [].concat(item) : [])}, + Balance: { + findOne: jest.fn().mockResolvedValue(balance === undefined ? null : { + id: 'buyer', + balance, + bank: 0, + save: jest.fn().mockResolvedValue() + }), + create: jest.fn().mockResolvedValue() + } + } + } + } + }; +} + +describe('buyShopItem', () => { + test('replies notFound when no item matches', async () => { + const interaction = makeShopInteraction({item: null}); + await eco.buyShopItem(interaction, 'x', null); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'NF'})); + }); + + test('replies multipleMatches when the query is ambiguous', async () => { + const interaction = makeShopInteraction({ + item: [{ + id: 'a', + role: 'r', + price: 1, + name: 'A' + }, { + id: 'b', + role: 'r2', + price: 1, + name: 'B' + }] + }); + await eco.buyShopItem(interaction, null, 'dup'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'MULTI'})); + }); + + test('rejects re-buying an item the member already owns', async () => { + const interaction = makeShopInteraction({ + item: { + id: 'a', + role: 'role-a', + price: 10, + name: 'A' + }, + memberHasRole: true + }); + await eco.buyShopItem(interaction, 'a', null); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'REBUY'})); + expect(interaction.member.roles.add).not.toHaveBeenCalled(); + }); + + test('rejects when the buyer cannot afford the item', async () => { + const interaction = makeShopInteraction({ + item: { + id: 'a', + role: 'role-a', + price: 100, + name: 'A' + }, + balance: 50 + }); + await eco.buyShopItem(interaction, 'a', null); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'POOR'})); + }); + + test('grants the role and confirms when the buyer can afford it', async () => { + const interaction = makeShopInteraction({ + item: { + id: 'a', + role: 'role-a', + price: 30, + name: 'Cool' + }, + balance: 100 + }); + await eco.buyShopItem(interaction, 'a', null); + expect(interaction.member.roles.add).toHaveBeenCalledWith('role-a'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'BUY'})); + }); + + test('returns early for a falsy interaction', async () => { + await expect(eco.buyShopItem(null, 'a', null)).resolves.toBeUndefined(); + }); +}); + +describe('createShopItemAPI', () => { + test('resolves with the duplicate message when an item already exists', async () => { + const client = makeClient({shopFindOne: {id: 'a'}}); + const res = await eco.createShopItemAPI('a', 'Name', 10, 'role', client); + expect(res).toContain('item-duplicate'); + expect(client.models['economy-system'].Shop.create).not.toHaveBeenCalled(); + }); + + test('creates the item and resolves with the created message when unique', async () => { + const client = makeClient({shopFindOne: null}); + const res = await eco.createShopItemAPI('a', 'Name', 10, 'role', client); + expect(client.models['economy-system'].Shop.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'a', + name: 'Name', + price: 10, + role: 'role' + }) + ); + expect(res).toContain('created-item'); + }); +}); + +describe('deleteShopItemAPI', () => { + function clientWith(items) { + const client = makeClient(); + client.models['economy-system'].Shop.findAll = jest.fn().mockResolvedValue(items); + return client; + } + + test('reports when more than one item matches', async () => { + const res = await eco.deleteShopItemAPI('n', 'i', clientWith([{}, {}])); + expect(res).toBe('More than one item was found'); + }); + + test('reports when no item matches', async () => { + const res = await eco.deleteShopItemAPI('n', 'i', clientWith([])); + expect(res).toBe('No item was found'); + }); + + test('destroys the single matching item', async () => { + const item = {destroy: jest.fn().mockResolvedValue()}; + const res = await eco.deleteShopItemAPI('Name', 'id', clientWith([item])); + expect(item.destroy).toHaveBeenCalled(); + expect(res).toContain('successfully'); + }); +}); + +describe('createShopMsg', () => { + function guildWith(memberSize) { + return {roles: {fetch: jest.fn().mockResolvedValue({members: {size: memberSize}})}}; + } + + test('renders a select menu component when items exist', async () => { + const items = [{ + dataValues: { + id: 'i1', + name: 'Sword', + price: 5, + role: 'r1' + } + }]; + const client = makeClient({shopItems: items}); + const out = await eco.createShopMsg(client, guildWith(3), true); + // embedType returns the optionsToKeep object for string input + expect(out.components).toHaveLength(1); + expect(out.components[0].components[0].options[0].value).toBe('i1'); + expect(out.content).toContain('Sword'); + }); + + test('omits components when there are no items', async () => { + const client = makeClient({shopItems: []}); + const out = await eco.createShopMsg(client, guildWith(0), false); + expect(out.components).toEqual([]); + }); +}); \ No newline at end of file diff --git a/tests/economy-system/events.test.js b/tests/economy-system/events.test.js new file mode 100644 index 00000000..5b328e70 --- /dev/null +++ b/tests/economy-system/events.test.js @@ -0,0 +1,283 @@ +/* + * Tests for the economy-system event handlers and the /shop command wrapper. + * + * messageCreate (random money drops): the early-return guards (not ready, no + * guild, bot author, wrong guild), the messageDrops==0 / ignored-channel + * short-circuits, the random-roll gate, the credited amount range, and that a + * drop notice is sent only when the author has not opted out. + * interactionCreate: only the shop select-menu in the right guild buys an item. + * botReady: redraws shop+leaderboard and schedules the daily refresh job. + * shop command: permission gating on add/delete/edit, and that buy/list don't + * require manager permissions. + */ + +const mockEditBalance = jest.fn().mockResolvedValue(); +const mockBuyShopItem = jest.fn().mockResolvedValue(); +const mockShopMsg = jest.fn().mockResolvedValue(); +const mockCreateLeaderboard = jest.fn().mockResolvedValue(); +const mockCreateShopItem = jest.fn().mockResolvedValue(); +const mockCreateShopMsg = jest.fn().mockResolvedValue('SHOP_MSG'); +const mockDeleteShopItem = jest.fn().mockResolvedValue(); +const mockUpdateShopItem = jest.fn().mockResolvedValue(); + +jest.mock('../../modules/economy-system/economy-system', () => ({ + editBalance: (...a) => mockEditBalance(...a), + buyShopItem: (...a) => mockBuyShopItem(...a), + shopMsg: (...a) => mockShopMsg(...a), + createLeaderboard: (...a) => mockCreateLeaderboard(...a), + createShopItem: (...a) => mockCreateShopItem(...a), + createShopMsg: (...a) => mockCreateShopMsg(...a), + deleteShopItem: (...a) => mockDeleteShopItem(...a), + updateShopItem: (...a) => mockUpdateShopItem(...a) +})); + +jest.mock('../../src/functions/helpers', () => ({ + formatDiscordUserName: (u) => (u && u.tag) || 'user' +})); + +const mockSchedule = jest.fn(() => ({})); +jest.mock('node-schedule', () => ({scheduleJob: (...a) => mockSchedule(...a)})); + +beforeEach(() => { + mockEditBalance.mockClear(); + mockBuyShopItem.mockClear(); + mockShopMsg.mockClear(); + mockCreateLeaderboard.mockClear(); + mockSchedule.mockClear(); + jest.spyOn(Math, 'random').mockRestore?.(); +}); + +describe('messageCreate money drops', () => { + const handler = require('../../modules/economy-system/events/messageCreate'); + + function makeClient(config = {}, {dropOptOut = null} = {}) { + return { + botReadyAt: Date.now(), + config: {guildID: 'g1'}, + logger: {info: jest.fn()}, + logChannel: null, + configurations: { + 'economy-system': { + config: { + messageDrops: 1, + msgDropsIgnoredChannels: [], + messageDropsMin: 5, + messageDropsMax: 6, + currencySymbol: '$', + ...config + } + } + }, + models: {'economy-system': {dropMsg: {findOne: jest.fn().mockResolvedValue(dropOptOut)}}} + }; + } + + function makeMessage(overrides = {}) { + return { + guild: {id: 'g1'}, + author: { + id: 'u1', + bot: false, + tag: 'U#1' + }, + channel: {id: 'c1'}, + reply: jest.fn().mockResolvedValue({delete: jest.fn()}), + ...overrides + }; + } + + test('does nothing before the bot is ready', async () => { + const client = makeClient(); + client.botReadyAt = null; + await handler.run(client, makeMessage()); + expect(mockEditBalance).not.toHaveBeenCalled(); + }); + + test('ignores bot authors and other guilds', async () => { + await handler.run(makeClient(), makeMessage({ + author: { + id: 'b', + bot: true + } + })); + await handler.run(makeClient(), makeMessage({guild: {id: 'other'}})); + expect(mockEditBalance).not.toHaveBeenCalled(); + }); + + test('messageDrops of 0 disables drops', async () => { + await handler.run(makeClient({messageDrops: 0}), makeMessage()); + expect(mockEditBalance).not.toHaveBeenCalled(); + }); + + test('skips ignored channels', async () => { + await handler.run(makeClient({msgDropsIgnoredChannels: ['c1']}), makeMessage()); + expect(mockEditBalance).not.toHaveBeenCalled(); + }); + + test('does nothing when the random roll misses (drop chance not hit)', async () => { + jest.spyOn(Math, 'random').mockReturnValue(0.0); // floor(0*1)=0 !== 1 -> miss + await handler.run(makeClient({messageDrops: 5}), makeMessage()); + expect(mockEditBalance).not.toHaveBeenCalled(); + Math.random.mockRestore(); + }); + + test('credits a drop and replies when the author has not opted out', async () => { + // messageDrops:1 -> floor(random*1)=0 ... need ===1; with messageDrops:2, random in [0.5,1) -> floor=1 + jest.spyOn(Math, 'random').mockReturnValue(0.5); + const client = makeClient({ + messageDrops: 2, + messageDropsMin: 10, + messageDropsMax: 11 + }, {dropOptOut: null}); + const msg = makeMessage(); + await handler.run(client, msg); + expect(mockEditBalance).toHaveBeenCalledWith(client, 'u1', 'add', expect.any(Number)); + expect(msg.reply).toHaveBeenCalled(); + Math.random.mockRestore(); + }); + + test('does not send a reply when the author opted out of drop messages', async () => { + jest.spyOn(Math, 'random').mockReturnValue(0.5); + const client = makeClient({messageDrops: 2}, {dropOptOut: {id: 'u1'}}); + const msg = makeMessage(); + await handler.run(client, msg); + expect(mockEditBalance).toHaveBeenCalled(); + expect(msg.reply).not.toHaveBeenCalled(); + Math.random.mockRestore(); + }); +}); + +describe('interactionCreate shop select', () => { + const handler = require('../../modules/economy-system/events/interactionCreate'); + + function makeInteraction(overrides = {}) { + return { + guild: {id: 'g1'}, + isSelectMenu: () => true, + customId: 'economy-system_shop-select', + values: ['item-id'], + deferReply: jest.fn().mockResolvedValue(), + ...overrides + }; + } + + const client = { + botReadyAt: Date.now(), + config: {guildID: 'g1'} + }; + + test('buys the selected item', async () => { + const interaction = makeInteraction(); + await handler.run(client, interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ephemeral: true}); + expect(mockBuyShopItem).toHaveBeenCalledWith(interaction, 'item-id', null); + }); + + test('ignores non-select interactions', async () => { + const interaction = makeInteraction({isSelectMenu: () => false}); + await handler.run(client, interaction); + expect(mockBuyShopItem).not.toHaveBeenCalled(); + }); + + test('ignores a foreign customId', async () => { + const interaction = makeInteraction({customId: 'other'}); + await handler.run(client, interaction); + expect(mockBuyShopItem).not.toHaveBeenCalled(); + }); + + test('does nothing before the bot is ready', async () => { + const interaction = makeInteraction(); + await handler.run({ + botReadyAt: null, + config: {guildID: 'g1'} + }, interaction); + expect(mockBuyShopItem).not.toHaveBeenCalled(); + }); +}); + +describe('botReady', () => { + const handler = require('../../modules/economy-system/events/botReady'); + test('redraws the shop + leaderboard and schedules a daily refresh', async () => { + const client = {jobs: []}; + await handler.run(client); + expect(mockShopMsg).toHaveBeenCalledWith(client); + expect(mockCreateLeaderboard).toHaveBeenCalledWith(client); + expect(mockSchedule).toHaveBeenCalledWith('1 0 * * *', expect.any(Function)); + expect(client.jobs).toHaveLength(1); + }); +}); + +describe('shop command permission gating', () => { + const shop = require('../../modules/economy-system/commands/shop'); + + function makeInteraction({ + userId = 'u', + shopManagers = [], + botOperators = [] + } = {}) { + return { + user: {id: userId}, + reply: jest.fn().mockResolvedValue(), + deferReply: jest.fn().mockResolvedValue(), + guild: {}, + options: {getString: jest.fn().mockReturnValue(null)}, + client: { + config: {botOperators}, + strings: {not_enough_permissions: 'NOPE'}, + configurations: { + 'economy-system': { + config: { + shopManagers, + publicCommandReplies: false + } + } + } + } + }; + } + + test('add is rejected for a non-manager', async () => { + const interaction = makeInteraction({userId: 'rando'}); + await shop.subcommands.add(interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'NOPE'})); + expect(mockCreateShopItem).not.toHaveBeenCalled(); + }); + + test('add is allowed for a shop manager', async () => { + const interaction = makeInteraction({ + userId: 'mgr', + shopManagers: ['mgr'] + }); + await shop.subcommands.add(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + expect(mockCreateShopItem).toHaveBeenCalledWith(interaction); + }); + + test('delete is allowed for a bot operator', async () => { + const interaction = makeInteraction({ + userId: 'op', + botOperators: ['op'] + }); + await shop.subcommands.delete(interaction); + expect(mockDeleteShopItem).toHaveBeenCalledWith(interaction); + }); + + test('edit is rejected for a non-manager', async () => { + const interaction = makeInteraction({userId: 'rando'}); + await shop.subcommands.edit(interaction); + expect(mockUpdateShopItem).not.toHaveBeenCalled(); + }); + + test('buy never requires manager permissions', async () => { + const interaction = makeInteraction({userId: 'rando'}); + await shop.subcommands.buy(interaction); + expect(mockBuyShopItem).toHaveBeenCalled(); + }); + + test('list renders the shop without a permission check', async () => { + const interaction = makeInteraction({userId: 'rando'}); + await shop.subcommands.list(interaction); + expect(mockCreateShopMsg).toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith('SHOP_MSG'); + }); +}); \ No newline at end of file diff --git a/tests/economy-system/leaderboardAndShopMsg.test.js b/tests/economy-system/leaderboardAndShopMsg.test.js new file mode 100644 index 00000000..98fae113 --- /dev/null +++ b/tests/economy-system/leaderboardAndShopMsg.test.js @@ -0,0 +1,169 @@ +/* + * Tests for the channel-publishing side effects in economy-system.js: + * createLeaderboard: short-circuits when no leaderboardChannel is set, logs + * fatal + bails when the channel can't be fetched, edits the last bot + * message when one exists, otherwise sends a fresh embed. + * shopMsg: short-circuits without a shopChannel, edits/sends the shop message. + * The Discord client + message collection are mocked. + */ +const eco = require('../../modules/economy-system/economy-system'); + +function makeMessages(botMessages) { + // Mimic a discord.js Collection.filter(...).last() + return { + filter: () => ({last: () => botMessages[botMessages.length - 1] || undefined}) + }; +} + +function makeClient({ + leaderboardChannel = '', + shopChannel = '', + channel = null, + balanceRows = [], + shopItems = [] + } = {}) { + return { + user: { + id: 'bot', + username: 'Bot', + avatarURL: () => 'https://cdn.example.com/a.png' + }, + strings: { + footer: 'f', + footerImgUrl: undefined, + disableFooterTimestamp: false + }, + logger: { + fatal: jest.fn(), + error: jest.fn(), + info: jest.fn() + }, + configurations: { + 'economy-system': { + config: { + leaderboardChannel, + shopChannel, + currencySymbol: '$', + startMoney: 0 + }, + strings: { + leaderboardEmbed: { + title: 'T', + description: 'D', + color: 'GREEN', + thumbnail: '', + image: '' + }, + itemString: '%itemName%', + shopMsg: 'SHOP %shopItems%' + } + } + }, + channels: {fetch: jest.fn().mockResolvedValue(channel)}, + models: { + 'economy-system': { + Balance: {findAll: jest.fn().mockResolvedValue(balanceRows)}, + Shop: {findAll: jest.fn().mockResolvedValue(shopItems)} + } + } + }; +} + +describe('createLeaderboard', () => { + test('does nothing when no leaderboard channel is configured', async () => { + const client = makeClient({leaderboardChannel: ''}); + await eco.createLeaderboard(client); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + test('logs fatal and bails when the channel cannot be fetched', async () => { + const client = makeClient({ + leaderboardChannel: 'lb', + channel: null + }); + await eco.createLeaderboard(client); + expect(client.logger.fatal).toHaveBeenCalled(); + }); + + test('sends a fresh embed when there is no previous bot message', async () => { + const channel = { + messages: {fetch: jest.fn().mockResolvedValue(makeMessages([]))}, + send: jest.fn().mockResolvedValue() + }; + const client = makeClient({ + leaderboardChannel: 'lb', + channel, + balanceRows: [{ + dataValues: { + id: 'u1', + balance: 10, + bank: 5 + } + }] + }); + await eco.createLeaderboard(client); + expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({embeds: expect.any(Array)})); + }); + + test('edits the existing bot leaderboard message when present', async () => { + const lastMsg = {edit: jest.fn().mockResolvedValue()}; + const channel = { + messages: {fetch: jest.fn().mockResolvedValue(makeMessages([lastMsg]))}, + send: jest.fn().mockResolvedValue() + }; + const client = makeClient({ + leaderboardChannel: 'lb', + channel, + balanceRows: [{ + dataValues: { + id: 'u1', + balance: 10, + bank: 5 + } + }] + }); + await eco.createLeaderboard(client); + expect(lastMsg.edit).toHaveBeenCalled(); + expect(channel.send).not.toHaveBeenCalled(); + }); +}); + +describe('shopMsg', () => { + test('does nothing without a shop channel', async () => { + const client = makeClient({shopChannel: ''}); + await eco.shopMsg(client); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + test('sends a fresh shop message when none exists', async () => { + const channel = { + guild: {roles: {fetch: jest.fn().mockResolvedValue({members: {size: 0}})}}, + messages: {fetch: jest.fn().mockResolvedValue(makeMessages([]))}, + send: jest.fn().mockResolvedValue() + }; + const client = makeClient({ + shopChannel: 'sc', + channel, + shopItems: [] + }); + await eco.shopMsg(client); + expect(channel.send).toHaveBeenCalled(); + }); + + test('edits the existing shop message when present', async () => { + const lastMsg = {edit: jest.fn().mockResolvedValue()}; + const channel = { + guild: {roles: {fetch: jest.fn().mockResolvedValue({members: {size: 1}})}}, + messages: {fetch: jest.fn().mockResolvedValue(makeMessages([lastMsg]))}, + send: jest.fn().mockResolvedValue() + }; + const client = makeClient({ + shopChannel: 'sc', + channel, + shopItems: [] + }); + await eco.shopMsg(client); + expect(lastMsg.edit).toHaveBeenCalled(); + expect(channel.send).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/economy-system/models.test.js b/tests/economy-system/models.test.js new file mode 100644 index 00000000..1f3c9583 --- /dev/null +++ b/tests/economy-system/models.test.js @@ -0,0 +1,83 @@ +/* + * Schema tests for the economy-system sequelize models. We stub Sequelize's + * Model.init so loading each model's static init() reveals its attribute map and + * options without a live database. We assert the table name, primary key, the + * declared columns, the startMoney-relevant defaults, and the module/name config + * each loader exposes. + */ +const {Model} = require('sequelize'); + +function loadModel(relPath) { + const original = Model.init; + Model.init = function (attributes, options) { + return { + attributes, + options + }; + }; + try { + const abs = require.resolve(relPath); + delete require.cache[abs]; + const mod = require(relPath); + const { + attributes, + options + } = mod.init({}); // fake sequelize + return { + mod, + attributes, + options + }; + } finally { + Model.init = original; + } +} + +test('Balance (user) model', () => { + const { + mod, + attributes, + options + } = loadModel('../../modules/economy-system/models/user'); + expect(options.tableName).toBe('economy_user'); + expect(attributes.id.primaryKey).toBe(true); + expect(Object.keys(attributes)).toEqual(expect.arrayContaining(['id', 'balance', 'bank'])); + expect(mod.config).toEqual({ + name: 'Balance', + module: 'economy-system' + }); +}); + +test('Shop model', () => { + const { + mod, + attributes, + options + } = loadModel('../../modules/economy-system/models/shop'); + expect(options.tableName).toBe('economy_shop'); + expect(attributes.id.primaryKey).toBe(true); + expect(Object.keys(attributes)).toEqual(expect.arrayContaining(['id', 'name', 'price', 'role'])); + expect(mod.config.name).toBe('Shop'); +}); + +test('cooldown model', () => { + const { + mod, + attributes, + options + } = loadModel('../../modules/economy-system/models/cooldowns'); + expect(options.tableName).toBe('economy_cooldowns'); + expect(Object.keys(attributes)).toEqual(expect.arrayContaining(['userId', 'command', 'timestamp'])); + expect(mod.config.name).toBe('cooldown'); +}); + +test('dropMsg model', () => { + const { + mod, + attributes, + options + } = loadModel('../../modules/economy-system/models/dropMsg'); + expect(options.tableName).toBe('economy_dropMsg'); + expect(attributes.id.primaryKey).toBe(true); + expect(mod.config.name).toBe('dropMsg'); +}); \ No newline at end of file diff --git a/tests/economy-system/payoutRandomness.test.js b/tests/economy-system/payoutRandomness.test.js new file mode 100644 index 00000000..134d4263 --- /dev/null +++ b/tests/economy-system/payoutRandomness.test.js @@ -0,0 +1,216 @@ +/* + * Randomness / fairness tests for the economy-system payout RNG + * (modules/economy-system/commands/economy-system.js). + * + * The economy core (editBalance/editBank/createLeaderboard) is mocked, but the + * RNG helpers randomIntFromInterval / randomElementFromArray are the REAL + * implementations so we exercise the genuine payout maths: + * - work credits a random amount within the configured [min,max] bounds + * - crime success credits a random amount within the configured bounds + * - crime is a ~50/50 win/lose coin flip (the success "chance") + * + * REGRESSION GUARD (previously a bug): work/crime used to call + * randomIntFromInterval(maxMoney, minMoney) with the arguments swapped, which + * collapsed the payout range to [min+1, max-1] - both configured endpoints were + * unreachable. The source now passes (minMoney, maxMoney) correctly, so payouts + * span the FULL inclusive [min, max]. These tests pin that corrected behaviour: + * both configured endpoints must be reachable. + * + * Tolerances are loose and justified inline so the suite cannot realistically + * flake. + */ +const mockEditBalance = jest.fn().mockResolvedValue(); +const mockEditBank = jest.fn().mockResolvedValue(); +const mockCreateLeaderboard = jest.fn().mockResolvedValue(); +jest.mock('../../modules/economy-system/economy-system', () => ({ + editBalance: (...a) => mockEditBalance(...a), + editBank: (...a) => mockEditBank(...a), + createLeaderboard: (...a) => mockCreateLeaderboard(...a) +})); + +// Real RNG; only embedType + formatDiscordUserName are replaced. +jest.mock('../../src/functions/helpers', () => { + const actual = jest.requireActual('../../src/functions/helpers'); + return { + ...actual, + embedType: (input, args, opts) => ({ + input, + args, + opts + }), + formatDiscordUserName: (u) => (u && u.tag) || 'user' + }; +}); + +const cmd = require('../../modules/economy-system/commands/economy-system'); + +function makeModels() { + // cooldown.findOne -> null means "no active cooldown" so the command proceeds + // and a row is created; that lets us call the subcommand repeatedly. + return { + cooldown: { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue() + }, + Balance: {findOne: jest.fn().mockResolvedValue(null)} + }; +} + +function makeInteraction(config = {}) { + const baseConfig = { + publicCommandReplies: true, + currencySymbol: '$', + workCooldown: 5, + crimeCooldown: 5, + minWorkMoney: 10, + maxWorkMoney: 50, + minCrimeMoney: 100, + maxCrimeMoney: 200, + ...config + }; + const interaction = { + user: { + id: 'me', + tag: 'Me#1', + toString: () => '<@me>' + }, + reply: () => { + }, + options: { + getUser: () => null, + get: () => undefined + }, + client: { + config: {botOperators: []}, + logChannel: null, + logger: { + info: () => { + }, + error: () => { + } + }, + configurations: { + 'economy-system': { + config: baseConfig, + strings: { + cooldown: 'COOLDOWN', + workSuccess: ['WORK %earned%'], + crimeSuccess: ['CRIME_WIN %earned%'], + crimeFail: ['CRIME_LOSE %loose%'] + } + } + }, + models: {'economy-system': makeModels()} + } + }; + interaction.str = interaction.client.configurations['economy-system'].strings; + interaction.config = interaction.client.configurations['economy-system'].config; + return interaction; +} + +/** + * Runs `work` once and returns the amount credited via editBalance(add). + * (cooldown.findOne resolves null each time, so every call proceeds.) + */ +async function runWork(config) { + // Clear ALL module-level mocks each call: jest records every call (incl. the + // full client object graph passed to createLeaderboard), so over tens of + // thousands of iterations un-cleared mock.calls would retain that many client + // graphs and exhaust the heap (CI OOM). Clearing keeps memory flat. + mockEditBalance.mockClear(); + mockEditBank.mockClear(); + mockCreateLeaderboard.mockClear(); + const interaction = makeInteraction(config); + await cmd.subcommands.work(interaction); + const addCall = mockEditBalance.mock.calls.find(c => c[2] === 'add'); + return addCall ? addCall[3] : null; +} + +describe('work payout bounds + coverage', () => { + test('every payout stays within the configured [min,max] box and both endpoints are reachable', async () => { + // Config min=10, max=50 => 41 inclusive outcomes. N = 30_000 runs. Every + // payout must lie within [10,50] (hard invariant) and, now the arg-order bug + // is fixed, the full span [10,50] must be observed. P(a given endpoint never + // appears in 30k draws) = (40/41)^30000 ~ 1e-322, so this cannot flake. + const N = 30_000; + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < N; i++) { + const amt = await runWork({ + minWorkMoney: 10, + maxWorkMoney: 50 + }); + expect(amt).toBeGreaterThanOrEqual(10); + expect(amt).toBeLessThanOrEqual(50); + expect(Number.isInteger(amt)).toBe(true); + if (amt < min) min = amt; + if (amt > max) max = amt; + } + // Corrected span: both configured endpoints 10 and 50 are reachable. + expect(min).toBe(10); + expect(max).toBe(50); + }); + + test('statistical: payouts are roughly uniform across the full [min,max] range', async () => { + // 41 outcomes in [10,50], N = 41_000 => expected 1000 each. Requiring every + // outcome within +/-30% (sigma ~= 31) needs a ~10-sigma miss to fail; + // false-failure probability is negligible (<<1e-20). + const N = 41_000; + const counts = {}; + for (let i = 0; i < N; i++) { + const amt = await runWork({ + minWorkMoney: 10, + maxWorkMoney: 50 + }); + counts[amt] = (counts[amt] || 0) + 1; + } + const expected = N / 41; + for (let v = 10; v <= 50; v++) { + expect(counts[v]).toBeGreaterThan(0); + expect(counts[v]).toBeGreaterThan(expected * 0.7); + expect(counts[v]).toBeLessThan(expected * 1.3); + } + }); +}); + +describe('crime success probability + payout', () => { + test('crime is a ~50/50 win/lose flip and wins pay within bounds', async () => { + // crime branches on Math.floor(Math.random()*2): exactly a fair coin. + // N = 60_000 => each side expected 30_000, sigma ~= 122. Requiring the win + // share in [0.45,0.55] is a 3000-count (~24-sigma) margin; cannot flake. + // On a win, editBalance(add) is called with randomIntFromInterval over the + // crime bounds [100,200]; on a loss it is not (editBalance(remove) / editBank). + const N = 60_000; + let wins = 0; + let winMin = Infinity; + let winMax = -Infinity; + for (let i = 0; i < N; i++) { + mockEditBalance.mockClear(); + mockEditBank.mockClear(); + mockCreateLeaderboard.mockClear(); + const interaction = makeInteraction({ + minCrimeMoney: 100, + maxCrimeMoney: 200 + }); + await cmd.subcommands.crime(interaction); + const addCall = mockEditBalance.mock.calls.find(c => c[2] === 'add'); + if (addCall) { + wins++; + const amt = addCall[3]; + // Within the configured [100,200] box (hard invariant). + expect(amt).toBeGreaterThanOrEqual(100); + expect(amt).toBeLessThanOrEqual(200); + if (amt < winMin) winMin = amt; + if (amt > winMax) winMax = amt; + } + } + const winShare = wins / N; + expect(winShare).toBeGreaterThan(0.45); + expect(winShare).toBeLessThan(0.55); + // Arg-order bug fixed: the win-payout span is the full [100,200]; both + // configured endpoints are reachable. Over ~30k wins both appear with + // overwhelming probability. + expect(winMin).toBe(100); + expect(winMax).toBe(200); + }); +}); \ No newline at end of file diff --git a/tests/economy-system/shopCrud.test.js b/tests/economy-system/shopCrud.test.js new file mode 100644 index 00000000..092978ce --- /dev/null +++ b/tests/economy-system/shopCrud.test.js @@ -0,0 +1,229 @@ +/* + * Tests for the interaction-driven shop CRUD helpers in economy-system.js: + * createShopItem: role-too-high guard, price<=0 guard, duplicate guard, and + * the create-and-confirm happy path. + * deleteShopItem: ambiguous-match, no-match, and the single-match destroy. + * updateShopItem: missing-id guard, item-not-found guard, the new-name + * collision guard, and applying new name/price/role to the row. + * shopChannel is left empty so the side-effecting shopMsg() short-circuits. + */ +const eco = require('../../modules/economy-system/economy-system'); + +const STRINGS = { + itemCreate: 'CREATE', + itemDuplicate: 'DUP', + itemDelete: 'DELETE', + itemEdit: 'EDIT', + multipleMatches: 'MULTI', + noMatches: 'NOMATCH' +}; + +function makeInteraction({ + options = {}, + shopFindOne = null, + shopFindAll = [], + roleHigher = true + } = {}) { + return { + editReply: jest.fn().mockResolvedValue(), + user: {tag: 'Admin#1'}, + guild: {members: {me: {roles: {highest: {comparePositionTo: () => (roleHigher ? 1 : -1)}}}}}, + options: { + get: jest.fn((name) => (name in options ? {value: options[name]} : undefined)), + getRole: jest.fn((name) => options[`role_${name}`] ?? null), + getInteger: jest.fn((name) => (options[name] ?? null)) + }, + client: { + logger: {info: jest.fn()}, + logChannel: null, + configurations: { + 'economy-system': { + config: { + shopChannel: '', + currencySymbol: '$' + }, + strings: STRINGS + } + }, + models: { + 'economy-system': { + Shop: { + findOne: jest.fn().mockResolvedValue(shopFindOne), + findAll: jest.fn().mockResolvedValue(shopFindAll), + create: jest.fn().mockResolvedValue() + } + } + } + } + }; +} + +describe('createShopItem', () => { + function createInteraction(over = {}) { + const { + options: optOver, + ...rest + } = over; + return makeInteraction({ + options: { + 'item-name': 'Sword', + 'item-id': 'sword', + price: 10, + role_role: { + id: 'role1', + name: 'VIP' + }, ...optOver + }, + ...rest + }); + } + + test('rejects a role higher than the bot', async () => { + const interaction = createInteraction({roleHigher: false}); + const res = await eco.createShopItem(interaction); + expect(res).toContain('role-to-high'); + expect(interaction.client.models['economy-system'].Shop.create).not.toHaveBeenCalled(); + }); + + test('rejects a non-positive price', async () => { + const interaction = createInteraction({options: {price: 0}}); + const res = await eco.createShopItem(interaction); + expect(res).toContain('price-less-than-zero'); + }); + + test('rejects a duplicate item', async () => { + const interaction = createInteraction({shopFindOne: {id: 'sword'}}); + const res = await eco.createShopItem(interaction); + expect(res).toContain('item-duplicate'); + expect(interaction.client.models['economy-system'].Shop.create).not.toHaveBeenCalled(); + }); + + test('creates the item and confirms', async () => { + const interaction = createInteraction({shopFindOne: null}); + const res = await eco.createShopItem(interaction); + expect(interaction.client.models['economy-system'].Shop.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'sword', + name: 'Sword', + price: 10, + role: 'role1' + }) + ); + expect(res).toContain('created-item'); + }); +}); + +describe('deleteShopItem', () => { + test('reports an ambiguous match', async () => { + const interaction = makeInteraction({ + options: { + 'item-name': 'x', + 'item-id': 'y' + }, + shopFindAll: [{}, {}] + }); + await eco.deleteShopItem(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'MULTI'})); + }); + + test('reports no match', async () => { + const interaction = makeInteraction({ + options: {'item-id': 'ghost'}, + shopFindAll: [] + }); + await eco.deleteShopItem(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'NOMATCH'})); + }); + + test('destroys a single match', async () => { + const item = { + name: 'Sword', + id: 'sword', + destroy: jest.fn().mockResolvedValue() + }; + const interaction = makeInteraction({ + options: {'item-id': 'sword'}, + shopFindAll: [item] + }); + await eco.deleteShopItem(interaction); + expect(item.destroy).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'DELETE'})); + }); +}); + +describe('updateShopItem', () => { + test('rejects a missing id', async () => { + const interaction = makeInteraction({options: {'item-id': ''}}); + await eco.updateShopItem(interaction); + expect(interaction.editReply).toHaveBeenCalledWith('Please use the id!'); + }); + + test('reports when the item is not found', async () => { + const interaction = makeInteraction({ + options: {'item-id': 'ghost'}, + shopFindOne: null + }); + await eco.updateShopItem(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'NOMATCH'})); + }); + + test('applies new name, price and role', async () => { + const item = { + id: 'sword', + name: 'Old', + price: 1, + role: 'r0', + save: jest.fn().mockResolvedValue() + }; + const interaction = makeInteraction({ + options: { + 'item-id': 'sword', + 'item-new-name': 'New Name', + 'new-price': 99 + }, + shopFindOne: item + }); + // wire getRole + getInteger to return the edit values + interaction.options.getRole = jest.fn((name) => (name === 'new-role' ? { + id: 'r9', + name: 'R9' + } : null)); + interaction.options.getInteger = jest.fn((name) => (name === 'new-price' ? 99 : null)); + // First findOne resolves the item being edited; the second (collision check) finds nothing. + interaction.client.models['economy-system'].Shop.findOne = jest.fn() + .mockResolvedValueOnce(item) + .mockResolvedValueOnce(null); + await eco.updateShopItem(interaction); + expect(item.name).toBe('New Name'); + expect(item.price).toBe(99); + expect(item.role).toBe('r9'); + expect(item.save).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'EDIT'})); + }); + + test('rejects a new name that collides with another item', async () => { + const item = { + id: 'sword', + name: 'Old', + price: 1, + role: 'r0', + save: jest.fn() + }; + const interaction = makeInteraction({ + options: { + 'item-id': 'sword', + 'item-new-name': 'Taken' + }, + shopFindOne: item + }); + interaction.options.getRole = jest.fn(() => null); + interaction.options.getInteger = jest.fn(() => null); + // First findOne resolves the item; the second (collision check) finds a different item using the new name. + interaction.client.models['economy-system'].Shop.findOne = jest.fn() + .mockResolvedValueOnce(item) + .mockResolvedValueOnce({id: 'other'}); + await eco.updateShopItem(interaction); + expect(item.save).not.toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'DUP'})); + }); +}); \ No newline at end of file diff --git a/tests/fun/random.test.js b/tests/fun/random.test.js new file mode 100644 index 00000000..8a6a691b --- /dev/null +++ b/tests/fun/random.test.js @@ -0,0 +1,130 @@ +/* + * Tests for the fun module's /random subcommands. We mock helpers (embedType, + * randomIntFromInterval, randomElementFromArray) and the ikea-name generator so + * we can assert on the computed interpolation args rather than rendered embeds. + * Covers: + * - number: default min/max (1..42) when no options given, passthrough of + * provided min/max, and that the rolled number uses those bounds + * - ikea-name: syllable count is capped at 20, default randomized 1..4 path + * - dice: rolls 1..6 + * - coinflip: localizes one of the two sides + * - 8ball: answers with an element from the configured pool + */ +const mockEmbedType = jest.fn((input, args) => ({ + input, + args +})); +const mockRandomInt = jest.fn(); +const mockRandomElement = jest.fn(); +const mockIkea = jest.fn(() => 'BJURSTA'); + +jest.mock('../../src/functions/helpers', () => ({ + embedType: (...a) => mockEmbedType(...a), + randomIntFromInterval: (...a) => mockRandomInt(...a), + randomElementFromArray: (...a) => mockRandomElement(...a) +})); +jest.mock('@scderox/ikea-name-generator', () => ({generateIkeaName: (...a) => mockIkea(...a)})); + +const {subcommands} = require('../../modules/fun/commands/random'); + +function makeInteraction(opts = {}) { + const config = { + randomNumberMessage: 'NUM', + ikeaMessage: 'IKEA', + diceRollMessage: 'DICE', + coinFlipMessage: 'COIN', + '8ballMessage': 'BALL', + '8BallMessages': ['Yes', 'No', 'Maybe'] + }; + return { + reply: jest.fn(), + client: {configurations: {fun: {config}}}, + options: {getNumber: jest.fn((name) => (name in opts ? opts[name] : null))} + }; +} + +beforeEach(() => { + mockEmbedType.mockClear(); + mockRandomInt.mockReset(); + mockRandomElement.mockReset(); + mockIkea.mockClear(); +}); + +describe('number', () => { + test('defaults to 1..42 and rolls within those bounds', () => { + mockRandomInt.mockReturnValue(17); + const interaction = makeInteraction(); + subcommands.number(interaction); + expect(mockRandomInt).toHaveBeenCalledWith(1, 42); + const args = mockEmbedType.mock.calls[0][1]; + expect(args['%min%']).toBe(1); + expect(args['%max%']).toBe(42); + expect(args['%number%']).toBe(17); + }); + + test('uses provided min/max', () => { + mockRandomInt.mockReturnValue(8); + const interaction = makeInteraction({ + min: 5, + max: 10 + }); + subcommands.number(interaction); + expect(mockRandomInt).toHaveBeenCalledWith(5, 10); + const args = mockEmbedType.mock.calls[0][1]; + expect(args['%min%']).toBe(5); + expect(args['%max%']).toBe(10); + }); +}); + +describe('ikea-name', () => { + test('caps the syllable count at 20', () => { + const interaction = makeInteraction({'syllable-count': 50}); + subcommands['ikea-name'](interaction); + expect(mockIkea).toHaveBeenCalledWith(20); + }); + + test('passes through a small explicit count', () => { + const interaction = makeInteraction({'syllable-count': 3}); + subcommands['ikea-name'](interaction); + expect(mockIkea).toHaveBeenCalledWith(3); + }); + + test('uses a randomized 1..4 count when none is given', () => { + const interaction = makeInteraction(); + subcommands['ikea-name'](interaction); + const count = mockIkea.mock.calls[0][0]; + expect(count).toBeGreaterThanOrEqual(1); + expect(count).toBeLessThanOrEqual(4); + }); +}); + +describe('dice', () => { + test('rolls a six-sided die', () => { + mockRandomInt.mockReturnValue(4); + const interaction = makeInteraction(); + subcommands.dice(interaction); + expect(mockRandomInt).toHaveBeenCalledWith(1, 6); + expect(mockEmbedType.mock.calls[0][1]['%number%']).toBe(4); + }); +}); + +describe('coinflip', () => { + test('localizes one of the two sides', () => { + mockRandomInt.mockReturnValue(2); + const interaction = makeInteraction(); + subcommands.coinflip(interaction); + expect(mockRandomInt).toHaveBeenCalledWith(1, 2); + // localize stub renders "fun.dice-site-" + expect(mockEmbedType.mock.calls[0][1]['%site%']).toBe('fun.dice-site-2'); + }); +}); + +describe('8ball', () => { + test('answers with an element from the configured pool', () => { + mockRandomElement.mockImplementation(arr => arr[1]); + const interaction = makeInteraction(); + subcommands['8ball'](interaction); + expect(mockRandomElement).toHaveBeenCalledWith(['Yes', 'No', 'Maybe']); + expect(mockEmbedType.mock.calls[0][1]['%answer%']).toBe('No'); + }); +}); \ No newline at end of file diff --git a/tests/fun/randomFairness.test.js b/tests/fun/randomFairness.test.js new file mode 100644 index 00000000..f2ee7d87 --- /dev/null +++ b/tests/fun/randomFairness.test.js @@ -0,0 +1,159 @@ +/* + * Randomness / fairness tests for the fun module's /random subcommands. + * + * Unlike tests/fun/random.test.js (which mocks the RNG to assert wiring), here + * we use the REAL randomIntFromInterval / randomElementFromArray from helpers + * and only mock embedType so we can read back the rolled value from the + * interpolation args. This exercises the actual RNG path end to end: + * - /random number: inclusive bounds + roughly uniform over the range + * - dice: faces 1..6 all reachable + fair + * - coinflip: ~50/50 over the two sides + * - 8ball: covers every configured answer over N + * + * Statistical tolerances are loose and justified inline so the suite cannot + * realistically flake. + */ +// Records only the most recent args (no growing history) so hot loops stay fast. +let lastArgs = null; +const mockEmbedType = (input, args) => { + lastArgs = args; + return { + input, + args + }; +}; + +// embedType is the only helper we replace; randomIntFromInterval and +// randomElementFromArray are the genuine implementations under test. +jest.mock('../../src/functions/helpers', () => { + const actual = jest.requireActual('../../src/functions/helpers'); + return { + ...actual, + embedType: (input, args) => mockEmbedType(input, args) + }; +}); +jest.mock('@scderox/ikea-name-generator', () => ({generateIkeaName: () => 'BJURSTA'})); + +const {subcommands} = require('../../modules/fun/commands/random'); + +function makeInteraction(opts = {}) { + const config = { + randomNumberMessage: 'NUM', + ikeaMessage: 'IKEA', + diceRollMessage: 'DICE', + coinFlipMessage: 'COIN', + '8ballMessage': 'BALL', + '8BallMessages': ['Yes', 'No', 'Maybe', 'Ask again'] + }; + return { + reply: () => { + }, + client: {configurations: {fun: {config}}}, + options: {getNumber: (name) => (name in opts ? opts[name] : null)} + }; +} + +beforeEach(() => { + lastArgs = null; +}); + +/** Runs a subcommand once against the given interaction and returns the rolled args. */ +function rollArgs(sub, interaction) { + sub(interaction); + return lastArgs; +} + +describe('/random number', () => { + test('statistical: stays inside [3,8] inclusive, hits both ends, roughly uniform', () => { + // Range 3..8 (6 values), N = 120_000 => expected 20_000 per value. + // Requiring every value present and within +/-25% (sigma ~= 129) needs a + // ~39-sigma miss to fail; false-failure probability <<1e-100. + const N = 120_000; + const interaction = makeInteraction({ + min: 3, + max: 8 + }); + const counts = {}; + for (let i = 0; i < N; i++) { + const n = rollArgs(subcommands.number, interaction)['%number%']; + expect(n).toBeGreaterThanOrEqual(3); + expect(n).toBeLessThanOrEqual(8); + counts[n] = (counts[n] || 0) + 1; + } + const expected = N / 6; + for (let v = 3; v <= 8; v++) { + expect(counts[v]).toBeGreaterThan(0); // every value reachable incl. both bounds + expect(counts[v]).toBeGreaterThan(expected * 0.75); + expect(counts[v]).toBeLessThan(expected * 1.25); + } + expect(counts[3]).toBeGreaterThan(0); + expect(counts[8]).toBeGreaterThan(0); + }); +}); + +describe('dice', () => { + test('statistical: all six faces reachable and fair, never 0 or 7', () => { + // N = 120_000 over 6 faces => expected 20_000 each. Same +/-25% / ~39-sigma + // margin as above; cannot realistically flake. + const N = 120_000; + const interaction = makeInteraction(); + const counts = [0, 0, 0, 0, 0, 0, 0, 0]; + for (let i = 0; i < N; i++) { + const n = rollArgs(subcommands.dice, interaction)['%number%']; + expect(n).toBeGreaterThanOrEqual(1); + expect(n).toBeLessThanOrEqual(6); + counts[n]++; + } + expect(counts[0]).toBe(0); + expect(counts[7]).toBe(0); + const expected = N / 6; + for (let f = 1; f <= 6; f++) { + expect(counts[f]).toBeGreaterThan(expected * 0.75); + expect(counts[f]).toBeLessThan(expected * 1.25); + } + }); +}); + +describe('coinflip', () => { + test('statistical: ~50/50 between the two sides', () => { + // A fair coin over N = 100_000 flips: each side expected 50_000, sigma ~= 158. + // Requiring each side in [0.45, 0.55] is a 5000-count (~32-sigma) margin, so a + // false failure is astronomically unlikely (<<1e-100). The localize stub maps + // the two outcomes to "fun.dice-site-1" / "fun.dice-site-2". + const N = 100_000; + const interaction = makeInteraction(); + const counts = {}; + for (let i = 0; i < N; i++) { + const site = rollArgs(subcommands.coinflip, interaction)['%site%']; + counts[site] = (counts[site] || 0) + 1; + } + const sides = Object.keys(counts); + expect(sides.sort()).toEqual(['fun.dice-site-1', 'fun.dice-site-2']); + for (const side of sides) { + const share = counts[side] / N; + expect(share).toBeGreaterThan(0.45); + expect(share).toBeLessThan(0.55); + } + }); +}); + +describe('8ball', () => { + test('statistical: covers every configured answer roughly uniformly', () => { + // 4 answers, N = 80_000 => expected 20_000 each. +/-25% (sigma ~= 122) needs a + // ~41-sigma deviation to fail; negligible false-failure probability. + const N = 80_000; + const interaction = makeInteraction(); + const counts = {}; + for (let i = 0; i < N; i++) { + const answer = rollArgs(subcommands['8ball'], interaction)['%answer%']; + counts[answer] = (counts[answer] || 0) + 1; + } + const pool = ['Yes', 'No', 'Maybe', 'Ask again']; + const expected = N / pool.length; + for (const answer of pool) { + expect(counts[answer]).toBeGreaterThan(0); + expect(counts[answer]).toBeGreaterThan(expected * 0.75); + expect(counts[answer]).toBeLessThan(expected * 1.25); + } + }); +}); \ No newline at end of file diff --git a/tests/fun/socialCommands.test.js b/tests/fun/socialCommands.test.js new file mode 100644 index 00000000..a9223c2f --- /dev/null +++ b/tests/fun/socialCommands.test.js @@ -0,0 +1,95 @@ +/* + * Tests for the fun module's social commands (hug, kiss, pat, slap). They share + * one behaviour: targeting yourself is rejected with an ephemeral reply and no + * deferral; targeting someone else defers first, then editReplies with an image + * attachment chosen from the configured pool. We assert the self-target guard, + * the defer-before-editReply ordering, that reply() is NOT used on the happy + * path, and that the chosen image comes from the configured list. + */ +const hug = require('../../modules/fun/commands/hug'); +const kiss = require('../../modules/fun/commands/kiss'); +const pat = require('../../modules/fun/commands/pat'); +const slap = require('../../modules/fun/commands/slap'); + +const COMMANDS = [ + { + name: 'hug', + mod: hug, + images: ['hug1.gif', 'hug2.gif'], + cfgKey: 'hugImages', + msgKey: 'hugMessage' + }, + { + name: 'kiss', + mod: kiss, + images: ['kiss1.gif'], + cfgKey: 'kissImages', + msgKey: 'kissMessage' + }, + { + name: 'pat', + mod: pat, + images: ['pat1.gif', 'pat2.gif'], + cfgKey: 'patImages', + msgKey: 'patMessage' + }, + { + name: 'slap', + mod: slap, + images: ['slap1.gif'], + cfgKey: 'slapImages', + msgKey: 'slapMessage' + } +]; + +function makeInteraction(targetUser, cfg) { + return { + user: {id: 'author'}, + client: {configurations: {fun: {config: cfg}}}, + options: {getUser: jest.fn().mockReturnValue(targetUser)}, + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue() + }; +} + +describe.each(COMMANDS)('$name command', ({ + mod, + images, + cfgKey, + msgKey + }) => { + const cfg = { + [cfgKey]: images, + [msgKey]: 'the-message' + }; + + test('rejects targeting yourself with an ephemeral reply and no deferral', async () => { + const interaction = makeInteraction({id: 'author'}, cfg); + await mod.run(interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + expect(interaction.deferReply).not.toHaveBeenCalled(); + expect(interaction.editReply).not.toHaveBeenCalled(); + }); + + test('defers before editReply when targeting someone else', async () => { + const interaction = makeInteraction({id: 'target'}, cfg); + await mod.run(interaction); + expect(interaction.deferReply).toHaveBeenCalledTimes(1); + expect(interaction.editReply).toHaveBeenCalledTimes(1); + expect(interaction.reply).not.toHaveBeenCalled(); + const deferOrder = interaction.deferReply.mock.invocationCallOrder[0]; + expect(interaction.editReply.mock.invocationCallOrder[0]).toBeGreaterThan(deferOrder); + }); + + test('attaches an image drawn from the configured pool', async () => { + const interaction = makeInteraction({id: 'target'}, cfg); + await mod.run(interaction); + const payload = interaction.editReply.mock.calls[0][0]; + expect(payload.files).toHaveLength(1); + // The attachment wraps one of the configured image URLs. + const attachment = payload.files[0]; + const serialized = JSON.stringify(attachment); + expect(images.some(img => serialized.includes(img) || attachment.attachment === img)).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/guess-the-number/interactionCreate.test.js b/tests/guess-the-number/interactionCreate.test.js new file mode 100644 index 00000000..e2633c17 --- /dev/null +++ b/tests/guess-the-number/interactionCreate.test.js @@ -0,0 +1,82 @@ +/* + * Tests for the guess-the-number button handler: the leaderboard button renders + * a ranked embed (or an "empty" notice when there are no users), and the + * emoji-guide button replies with the legend. Verifies the DB query ordering + * options and the rendered description contents. + */ +const handler = require('../../modules/guess-the-number/events/interactionCreate'); + +function makeInteraction(customId) { + return { + customId, + reply: jest.fn().mockResolvedValue() + }; +} + +function makeClient(users) { + return { + models: {'guess-the-number': {User: {findAll: jest.fn().mockResolvedValue(users)}}} + }; +} + +test('leaderboard replies with an empty notice when no users exist', async () => { + const client = makeClient([]); + const interaction = makeInteraction('gtn-leaderboard'); + await handler.run(client, interaction); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.ephemeral).toBe(true); + expect(arg.content).toContain('guess-the-number.leaderboard-empty'); +}); + +test('leaderboard queries ordered by wins desc then totalGuesses asc, limited to 20', async () => { + const client = makeClient([{ + userID: 'u1', + wins: 3, + totalGuesses: 10 + }]); + await handler.run(client, makeInteraction('gtn-leaderboard')); + const opts = client.models['guess-the-number'].User.findAll.mock.calls[0][0]; + expect(opts.order).toEqual([['wins', 'DESC'], ['totalGuesses', 'ASC']]); + expect(opts.limit).toBe(20); +}); + +test('leaderboard renders a numbered embed listing each user mention and stats', async () => { + const users = [ + { + userID: 'a', + wins: 5, + totalGuesses: 12 + }, + { + userID: 'b', + wins: 2, + totalGuesses: 30 + } + ]; + const interaction = makeInteraction('gtn-leaderboard'); + await handler.run(makeClient(users), interaction); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.ephemeral).toBe(true); + const desc = arg.embeds[0].data.description; + expect(desc).toContain('**1.** <@a>'); + expect(desc).toContain('**2.** <@b>'); + expect(desc).toContain('5'); + expect(desc).toContain('30'); +}); + +test('emoji-guide button replies with the legend and does not hit the DB', async () => { + const client = makeClient([]); + const interaction = makeInteraction('gtn-reaction-meaning'); + await handler.run(client, interaction); + expect(client.models['guess-the-number'].User.findAll).not.toHaveBeenCalled(); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.ephemeral).toBe(true); + expect(arg.content).toContain('guess-the-number.guide-win'); +}); + +test('an unrelated customId is ignored', async () => { + const client = makeClient([]); + const interaction = makeInteraction('something-else'); + await handler.run(client, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/guess-the-number/manage.test.js b/tests/guess-the-number/manage.test.js new file mode 100644 index 00000000..c4077178 --- /dev/null +++ b/tests/guess-the-number/manage.test.js @@ -0,0 +1,226 @@ +/* + * Tests for the guess-the-number /guess-the-number management command + * (modules/guess-the-number/commands/manage.js). + * + * beforeSubcommand: admin-role gating + the "game channel mode" lockout. + * subcommands: + * - end: no-active-session guard, then lock + destroy the session + * - status: no-active-session guard, then report the running session + * - create: already-running guard, min>=max guard, number-out-of-range guards, + * and the happy path that calls startGame with the chosen number. + */ +const mockStartGame = jest.fn().mockResolvedValue(); +const mockLockChannel = jest.fn().mockResolvedValue(); +const mockRandomInt = jest.fn(() => 50); +jest.mock('../../modules/guess-the-number/guessTheNumber', () => ({startGame: (...a) => mockStartGame(...a)})); +jest.mock('../../src/functions/helpers', () => ({ + randomIntFromInterval: (...a) => mockRandomInt(...a), + embedType: (x) => ({content: x}), + lockChannel: (...a) => mockLockChannel(...a), + unlockChannel: jest.fn() +})); + +const cmd = require('../../modules/guess-the-number/commands/manage'); + +function roleCache(roleIds = []) { + const cache = new Map(roleIds.map(id => [id, {id}])); + cache.filter = (fn) => ({size: [...cache.values()].filter(fn).length}); + return cache; +} + +function makeInteraction({ + roleIds = ['admin'], + adminRoles = ['admin'], + channelEnabled = false, + channelId = 'chan', + gameChannelId = 'game', + session = null, + options = {}, + replied = false + } = {}) { + return { + replied, + channel: {id: channelId}, + user: {id: 'u1'}, + member: {roles: {cache: roleCache(roleIds)}}, + reply: jest.fn().mockResolvedValue(), + options: { + getInteger: jest.fn((name) => (name in options ? options[name] : null)) + }, + client: { + configurations: { + 'guess-the-number': { + config: {adminRoles}, + channel: { + enabled: channelEnabled, + channel: gameChannelId + } + } + }, + models: {'guess-the-number': {Channel: {findOne: jest.fn().mockResolvedValue(session)}}} + } + }; +} + +beforeEach(() => { + mockStartGame.mockClear(); + mockLockChannel.mockClear(); + mockRandomInt.mockClear().mockReturnValue(50); +}); + +describe('beforeSubcommand', () => { + test('rejects a member without an admin role', async () => { + const interaction = makeInteraction({ + roleIds: [], + adminRoles: ['admin'] + }); + await cmd.beforeSubcommand(interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + }); + + test('rejects management in the auto game channel', async () => { + const interaction = makeInteraction({ + channelEnabled: true, + channelId: 'game', + gameChannelId: 'game' + }); + await cmd.beforeSubcommand(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('gamechannel-modus'); + }); + + test('allows an admin in a normal channel (no reply)', async () => { + const interaction = makeInteraction({roleIds: ['admin']}); + await cmd.beforeSubcommand(interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + }); +}); + +describe('end', () => { + test('reports when no session is running', async () => { + const interaction = makeInteraction({session: null}); + await cmd.subcommands.end(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('session-not-running'); + }); + + test('locks the channel and destroys the session', async () => { + const session = {destroy: jest.fn().mockResolvedValue()}; + const interaction = makeInteraction({session}); + await cmd.subcommands.end(interaction); + expect(mockLockChannel).toHaveBeenCalled(); + expect(session.destroy).toHaveBeenCalled(); + expect(interaction.reply.mock.calls[0][0].content).toContain('session-ended-successfully'); + }); + + test('does nothing if the interaction was already replied to', async () => { + const interaction = makeInteraction({replied: true}); + await cmd.subcommands.end(interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + }); +}); + +describe('status', () => { + test('reports when no session is running', async () => { + const interaction = makeInteraction({session: null}); + await cmd.subcommands.status(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('session-not-running'); + }); + + test('prints the running session details', async () => { + const session = { + number: 42, + min: 1, + max: 100, + ownerID: 'owner', + guessCount: 7 + }; + const interaction = makeInteraction({session}); + await cmd.subcommands.status(interaction); + const content = interaction.reply.mock.calls[0][0].content; + expect(content).toContain('42'); + expect(content).toContain('<@owner>'); + expect(content).toContain('7'); + }); +}); + +describe('create', () => { + test('rejects when a session is already running', async () => { + const interaction = makeInteraction({ + session: {}, + options: { + min: 1, + max: 10 + } + }); + await cmd.subcommands.create(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('session-already-running'); + expect(mockStartGame).not.toHaveBeenCalled(); + }); + + test('rejects min >= max', async () => { + const interaction = makeInteraction({ + session: null, + options: { + min: 10, + max: 5 + } + }); + await cmd.subcommands.create(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('min-max-discrepancy'); + }); + + test('rejects a provided number above the max', async () => { + const interaction = makeInteraction({ + session: null, + options: { + min: 1, + max: 10, + number: 99 + } + }); + await cmd.subcommands.create(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('max-discrepancy'); + }); + + test('rejects a provided number below the min', async () => { + // randomIntFromInterval is only used when number is falsy; provide an explicit low number. + // number 0 is falsy so create falls back to random; use a number that passes max but fails min via mocked random + mockRandomInt.mockReturnValue(-5); + const interaction = makeInteraction({ + session: null, + options: { + min: 1, + max: 10 + } + }); + await cmd.subcommands.create(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('min-discrepancy'); + }); + + test('starts a game with the explicit number', async () => { + const interaction = makeInteraction({ + session: null, + options: { + min: 1, + max: 100, + number: 50 + } + }); + await cmd.subcommands.create(interaction); + expect(mockStartGame).toHaveBeenCalledWith(interaction.channel, 50, 1, 100, 'u1'); + expect(interaction.reply.mock.calls[0][0].content).toContain('created-successfully'); + }); + + test('falls back to a random number when none is provided', async () => { + mockRandomInt.mockReturnValue(7); + const interaction = makeInteraction({ + session: null, + options: { + min: 1, + max: 100 + } + }); + await cmd.subcommands.create(interaction); + expect(mockRandomInt).toHaveBeenCalledWith(1, 100); + expect(mockStartGame).toHaveBeenCalledWith(interaction.channel, 7, 1, 100, 'u1'); + }); +}); \ No newline at end of file diff --git a/tests/guess-the-number/messageCreate.test.js b/tests/guess-the-number/messageCreate.test.js new file mode 100644 index 00000000..a6d763d3 --- /dev/null +++ b/tests/guess-the-number/messageCreate.test.js @@ -0,0 +1,214 @@ +/* + * Behavioural tests for the guess-the-number messageCreate handler. Covers the + * guess-evaluation branches: invalid (non-numeric / out-of-range) guesses get a + * 🚫 reaction, wrong guesses get higher/lower arrows or ❌, a correct guess gets + * ✅, ends the game, records the winner and leaderboard stats. Also verifies the + * early-return guards (not ready, bot author, no active game). + */ +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn((x) => ({content: x})), + lockChannel: jest.fn().mockResolvedValue(), + randomIntFromInterval: jest.fn(() => 7) +})); +jest.mock('../../modules/guess-the-number/guessTheNumber', () => ({startGame: jest.fn().mockResolvedValue()})); + +const handler = require('../../modules/guess-the-number/events/messageCreate'); + +function makeRoleCache(roleIds = []) { + const cache = new Map(roleIds.map(id => [id, { + id, + client: null + }])); + cache.filter = (fn) => { + const out = [...cache.values()].filter(fn); + return {size: out.length}; + }; + return cache; +} + +function makeConfig(overrides = {}) { + return { + config: { + adminRoles: [], + enableLeaderboard: false, + higherLowerReactions: false, + endMessage: 'END', + ...(overrides.config || {}) + }, + channel: { + enabled: false, + channel: 'gamechannel', + minInt: 1, + maxInt: 10, ...(overrides.channel || {}) + } + }; +} + +function makeGame(overrides = {}) { + return { + min: 1, + max: 100, + number: 50, + guessCount: 0, + ended: false, + save: jest.fn().mockResolvedValue(), + ...overrides + }; +} + +function makeClient({ + game, + config = makeConfig(), + userStats + } = {}) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + configurations: {'guess-the-number': config}, + models: { + 'guess-the-number': { + Channel: {findOne: jest.fn().mockResolvedValue(game)}, + User: { + findOrCreate: jest.fn().mockResolvedValue([ + userStats || { + wins: 0, + totalGuesses: 0, + save: jest.fn().mockResolvedValue() + } + ]) + } + } + } + }; +} + +function makeMsg({ + content, + roleIds = [], + channelId = 'chan' + } = {}) { + const roleCache = makeRoleCache(roleIds); + // role objects need client.configurations for the admin-role filter + return { + author: { + bot: false, + id: 'user1', + toString: () => '<@user1>' + }, + guild: {id: 'g1'}, + channel: {id: channelId}, + content, + member: {roles: {cache: roleCache}}, + react: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue() + }; +} + +test('ignores messages before the bot is ready', async () => { + const client = makeClient({game: makeGame()}); + client.botReadyAt = null; + const msg = makeMsg({content: '50'}); + await handler.run(client, msg); + expect(client.models['guess-the-number'].Channel.findOne).not.toHaveBeenCalled(); +}); + +test('ignores bot authors and messages with no active game', async () => { + const botMsg = makeMsg({content: '50'}); + botMsg.author.bot = true; + await handler.run(makeClient({game: makeGame()}), botMsg); + expect(botMsg.react).not.toHaveBeenCalled(); + + const noGameClient = makeClient({game: null}); + const msg = makeMsg({content: '50'}); + await handler.run(noGameClient, msg); + expect(msg.react).not.toHaveBeenCalled(); +}); + +test('reacts 🚫 to a non-numeric guess', async () => { + const client = makeClient({game: makeGame()}); + const msg = makeMsg({content: 'hello'}); + await handler.run(client, msg); + expect(msg.react).toHaveBeenCalledWith('🚫'); +}); + +test('reacts 🚫 to a guess outside the configured range', async () => { + const client = makeClient({ + game: makeGame({ + min: 1, + max: 10 + }) + }); + const msg = makeMsg({content: '999'}); + await handler.run(client, msg); + expect(msg.react).toHaveBeenCalledWith('🚫'); +}); + +test('a wrong guess (no higher/lower) reacts ❌ and increments guessCount', async () => { + const game = makeGame({ + number: 50, + guessCount: 4 + }); + const client = makeClient({game}); + const msg = makeMsg({content: '40'}); + await handler.run(client, msg); + expect(game.guessCount).toBe(5); + expect(game.save).toHaveBeenCalled(); + expect(msg.react).toHaveBeenCalledWith('❌'); + expect(game.ended).toBe(false); +}); + +test('higher/lower mode points down when the secret is below the guess', async () => { + const game = makeGame({number: 20}); + const client = makeClient({ + game, + config: makeConfig({config: {higherLowerReactions: true}}) + }); + const msg = makeMsg({content: '80'}); // guess too high -> arrow down + await handler.run(client, msg); + expect(msg.react).toHaveBeenCalledWith('⬇'); +}); + +test('higher/lower mode points up when the secret is above the guess', async () => { + const game = makeGame({number: 90}); + const client = makeClient({ + game, + config: makeConfig({config: {higherLowerReactions: true}}) + }); + const msg = makeMsg({content: '10'}); // guess too low -> arrow up + await handler.run(client, msg); + expect(msg.react).toHaveBeenCalledWith('⬆'); +}); + +test('a correct guess reacts ✅, ends the game and records the winner', async () => { + const game = makeGame({ + number: 42, + guessCount: 9 + }); + const client = makeClient({game}); + const msg = makeMsg({content: '42'}); + await handler.run(client, msg); + expect(msg.react).toHaveBeenCalledWith('✅'); + expect(game.ended).toBe(true); + expect(game.winnerID).toBe('user1'); + expect(msg.reply).toHaveBeenCalled(); +}); + +test('a correct guess updates leaderboard win/guess stats when enabled', async () => { + const game = makeGame({number: 42}); + const userStats = { + wins: 0, + totalGuesses: 3, + save: jest.fn().mockResolvedValue() + }; + const client = makeClient({ + game, + config: makeConfig({config: {enableLeaderboard: true}}), + userStats + }); + const msg = makeMsg({content: '42'}); + await handler.run(client, msg); + // findOrCreate called for the guess and again for the win + expect(client.models['guess-the-number'].User.findOrCreate).toHaveBeenCalledTimes(2); + expect(userStats.totalGuesses).toBe(4); + expect(userStats.wins).toBe(1); +}); \ No newline at end of file diff --git a/tests/guess-the-number/models.test.js b/tests/guess-the-number/models.test.js new file mode 100644 index 00000000..a9854fc5 --- /dev/null +++ b/tests/guess-the-number/models.test.js @@ -0,0 +1,64 @@ +/* + * Schema tests for the guess-the-number Channel and User models. Stubs + * Model.init to inspect the attribute maps + options: table names, the + * autoincrement Channel PK and its guessCount/0 default, the User string PK with + * wins/0 + totalGuesses/0 defaults, and the module/name config exports. + */ +const {Model} = require('sequelize'); + +function loadModel(relPath) { + const original = Model.init; + Model.init = function (attributes, options) { + return { + attributes, + options + }; + }; + try { + const abs = require.resolve(relPath); + delete require.cache[abs]; + const mod = require(relPath); + const { + attributes, + options + } = mod.init({}); // fake sequelize + return { + mod, + attributes, + options + }; + } finally { + Model.init = original; + } +} + +test('Channel model', () => { + const { + mod, + attributes, + options + } = loadModel('../../modules/guess-the-number/models/Channel'); + expect(options.tableName).toBe('guess_the_number_Channel'); + expect(attributes.id.autoIncrement).toBe(true); + expect(attributes.guessCount.defaultValue).toBe(0); + expect(Object.keys(attributes)).toEqual(expect.arrayContaining([ + 'channelID', 'number', 'min', 'max', 'ownerID', 'winnerID', 'ended' + ])); + expect(mod.config).toEqual({ + name: 'Channel', + module: 'guess-the-number' + }); +}); + +test('User model', () => { + const { + mod, + attributes, + options + } = loadModel('../../modules/guess-the-number/models/User'); + expect(options.tableName).toBe('guess_the_number_Users'); + expect(attributes.userID.primaryKey).toBe(true); + expect(attributes.wins.defaultValue).toBe(0); + expect(attributes.totalGuesses.defaultValue).toBe(0); + expect(mod.config.name).toBe('User'); +}); \ No newline at end of file diff --git a/tests/guess-the-number/startGame.test.js b/tests/guess-the-number/startGame.test.js new file mode 100644 index 00000000..4908c913 --- /dev/null +++ b/tests/guess-the-number/startGame.test.js @@ -0,0 +1,182 @@ +/* + * Tests for guess-the-number's startGame (guessTheNumber.js) and the botReady + * auto-game bootstrap. + * + * startGame: creates the Channel row, unpins the bot's own old pinned messages, + * sends + pins the start message, includes a leaderboard button only when the + * leaderboard is enabled, and unlocks a previously locked channel. + * botReady: when the auto game channel is enabled, fetches the channel and + * starts a game only if none is already running; bails when the channel is + * missing or a game already exists. + */ +const mockEmbedType = jest.fn((input, args, opts) => ({ + input, + args, + opts +})); +const mockUnlockChannel = jest.fn().mockResolvedValue(); +const mockRandomInt = jest.fn(() => 50); +jest.mock('../../src/functions/helpers', () => ({ + embedType: (...a) => mockEmbedType(...a), + unlockChannel: (...a) => mockUnlockChannel(...a), + randomIntFromInterval: (...a) => mockRandomInt(...a) +})); + +const {startGame} = require('../../modules/guess-the-number/guessTheNumber'); + +function makePins(pins = []) { + return {values: () => pins}; +} + +function makeChannel({ + pins = [], + leaderboard = false, + channelLock = null + } = {}) { + const startMsg = {pin: jest.fn().mockResolvedValue()}; + const client = { + user: {id: 'bot'}, + configurations: { + 'guess-the-number': { + config: { + enableLeaderboard: leaderboard, + startMessage: 'START' + } + } + }, + models: { + 'guess-the-number': {Channel: {create: jest.fn().mockResolvedValue()}}, + ChannelLock: {findOne: jest.fn().mockResolvedValue(channelLock)} + } + }; + return { + id: 'chan', + client, + messages: {fetchPinned: jest.fn().mockResolvedValue(makePins(pins))}, + send: jest.fn().mockResolvedValue(startMsg), + _startMsg: startMsg + }; +} + +beforeEach(() => { + mockEmbedType.mockClear(); + mockUnlockChannel.mockClear(); +}); + +test('creates the channel row with the given parameters', async () => { + const channel = makeChannel(); + await startGame(channel, 42, 1, 100, 'owner'); + expect(channel.client.models['guess-the-number'].Channel.create).toHaveBeenCalledWith( + expect.objectContaining({ + channelID: 'chan', + number: 42, + min: 1, + max: 100, + ownerID: 'owner', + ended: false + }) + ); +}); + +test('unpins the bot\'s own old pinned messages only', async () => { + const botPin = { + author: {id: 'bot'}, + unpin: jest.fn().mockResolvedValue() + }; + const otherPin = { + author: {id: 'someone'}, + unpin: jest.fn().mockResolvedValue() + }; + const channel = makeChannel({pins: [botPin, otherPin]}); + await startGame(channel, 1, 1, 10); + expect(botPin.unpin).toHaveBeenCalled(); + expect(otherPin.unpin).not.toHaveBeenCalled(); +}); + +test('sends and pins the start message', async () => { + const channel = makeChannel(); + await startGame(channel, 1, 1, 10); + expect(channel.send).toHaveBeenCalled(); + expect(channel._startMsg.pin).toHaveBeenCalled(); + expect(mockEmbedType.mock.calls[0][1]).toEqual({ + '%min%': 1, + '%max%': 10 + }); +}); + +test('omits the leaderboard button when the leaderboard is disabled', async () => { + const channel = makeChannel({leaderboard: false}); + await startGame(channel, 1, 1, 10); + const buttons = mockEmbedType.mock.calls[0][2].components[0].components; + expect(buttons.find(b => b.customId === 'gtn-leaderboard')).toBeUndefined(); + expect(buttons.find(b => b.customId === 'gtn-reaction-meaning')).toBeDefined(); +}); + +test('includes the leaderboard button when enabled', async () => { + const channel = makeChannel({leaderboard: true}); + await startGame(channel, 1, 1, 10); + const buttons = mockEmbedType.mock.calls[0][2].components[0].components; + expect(buttons.find(b => b.customId === 'gtn-leaderboard')).toBeDefined(); +}); + +test('unlocks the channel if it was previously locked', async () => { + const channel = makeChannel({channelLock: {id: 'chan'}}); + await startGame(channel, 1, 1, 10); + expect(mockUnlockChannel).toHaveBeenCalledWith(channel, expect.any(String)); +}); + +test('does not unlock when no channel lock exists', async () => { + const channel = makeChannel({channelLock: null}); + await startGame(channel, 1, 1, 10); + expect(mockUnlockChannel).not.toHaveBeenCalled(); +}); + +describe('botReady auto-game', () => { + // Re-require with startGame mocked so we test only the bootstrap decisions. + jest.resetModules(); + const mockStartGame = jest.fn().mockResolvedValue(); + jest.doMock('../../modules/guess-the-number/guessTheNumber', () => ({startGame: (...a) => mockStartGame(...a)})); + jest.doMock('../../src/functions/helpers', () => ({randomIntFromInterval: () => 5})); + const botReady = require('../../modules/guess-the-number/events/botReady'); + + function makeClient({ + enabled = true, + channel = {id: 'game'}, + game = null + } = {}) { + return { + configurations: { + 'guess-the-number': { + channel: { + enabled, + channel: 'game', + minInt: 1, + maxInt: 10 + } + } + }, + guild: {channels: {fetch: jest.fn().mockResolvedValue(channel)}}, + models: {'guess-the-number': {Channel: {findOne: jest.fn().mockResolvedValue(game)}}} + }; + } + + beforeEach(() => mockStartGame.mockClear()); + + test('starts a game when none is running', async () => { + const client = makeClient({game: null}); + await botReady.run(client); + expect(mockStartGame).toHaveBeenCalled(); + }); + + test('does not start when a game is already running', async () => { + const client = makeClient({game: {id: 1}}); + await botReady.run(client); + expect(mockStartGame).not.toHaveBeenCalled(); + }); + + test('bails when the channel cannot be fetched', async () => { + const client = makeClient({channel: null}); + await botReady.run(client); + expect(mockStartGame).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/helpers/clientAware.test.js b/tests/helpers/clientAware.test.js new file mode 100644 index 00000000..2516f9cf --- /dev/null +++ b/tests/helpers/clientAware.test.js @@ -0,0 +1,184 @@ +const mainStub = require('../__stubs__/main'); +const helpers = require('../../src/functions/helpers'); +const {__test} = helpers; +const {MessageEmbed} = require('discord.js'); + +function resetClient() { + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'test-footer', + footerImgUrl: '', + disableFooterTimestamp: false + }; + mainStub.client.scnxSetup = false; + mainStub.client.user = null; + mainStub.client.guild = null; + mainStub.client.bcp47Locale = 'en-US'; +} + +beforeEach(resetClient); + +describe('safeSetFooter', () => { + test('uses client.strings.footer when no custom text provided', () => { + const client = { + strings: { + footer: 'default footer', + footerImgUrl: 'https://x/i.png' + } + }; + const embed = new MessageEmbed(); + helpers.safeSetFooter(embed, client); + expect(embed.data.footer).toEqual({ + text: 'default footer', + icon_url: 'https://x/i.png' + }); + }); + + test('customText overrides client.strings.footer', () => { + const client = {strings: {footer: 'default'}}; + const embed = new MessageEmbed(); + helpers.safeSetFooter(embed, client, 'custom!'); + expect(embed.data.footer.text).toBe('custom!'); + }); + + test('skips footer when both custom and client text are empty/whitespace', () => { + const client = {strings: {footer: ' '}}; + const embed = new MessageEmbed(); + helpers.safeSetFooter(embed, client); + expect(embed.data.footer).toBeUndefined(); + }); + + test('skips footer when client.strings is absent and no custom text', () => { + const embed = new MessageEmbed(); + helpers.safeSetFooter(embed, {}); + expect(embed.data.footer).toBeUndefined(); + }); + + test('returns the embed for chaining', () => { + const client = {strings: {footer: 'x'}}; + const embed = new MessageEmbed(); + expect(helpers.safeSetFooter(embed, client)).toBe(embed); + }); +}); + +describe('getGlobalArgs (internal)', () => { + test('returns empty object when client.user is null', () => { + expect(__test.getGlobalArgs()).toEqual({}); + }); + + test('includes bot variables when client.user is set', () => { + mainStub.client.user = { + id: 'bot-1', + tag: 'Bot#0000', + username: 'BotName', + displayName: 'Bot Display', + displayAvatarURL: () => 'https://x/avatar.png', + toString: () => '<@bot-1>' + }; + const args = __test.getGlobalArgs(); + expect(args['%botName%']).toBe('Bot Display'); + expect(args['%botID%']).toBe('bot-1'); + expect(args['%botAvatar%']).toBe('https://x/avatar.png'); + expect(args['%botTag%']).toBe('Bot#0000'); + expect(args['%botMention%']).toBe('<@bot-1>'); + }); + + test('falls back to username when displayName missing', () => { + mainStub.client.user = { + id: 'b', + tag: 'b#0', + username: 'OnlyUsername', + displayName: null, + displayAvatarURL: () => '', + toString: () => '<@b>' + }; + expect(__test.getGlobalArgs()['%botName%']).toBe('OnlyUsername'); + }); + + test('adds guild variables when client.guild is set', () => { + mainStub.client.user = { + id: 'b', + tag: 'b#0', + username: 'u', + displayName: 'u', + displayAvatarURL: () => '', + toString: () => '<@b>' + }; + mainStub.client.guild = { + id: 'g-1', + name: 'Test Guild', + iconURL: () => 'https://x/g.png' + }; + const args = __test.getGlobalArgs(); + expect(args['%guildID%']).toBe('g-1'); + expect(args['%guildName%']).toBe('Test Guild'); + expect(args['%guildIcon%']).toBe('https://x/g.png'); + }); + + test('always emits all timestamp placeholders', () => { + mainStub.client.user = { + id: 'b', + tag: 'b#0', + username: 'u', + displayName: 'u', + displayAvatarURL: () => '', + toString: () => '<@b>' + }; + const args = __test.getGlobalArgs(); + for (const key of ['%timestamp%', '%shortTime%', '%longTime%', '%shortDate%', '%longDate%', '%shortDateTime%', '%longDateTime%', '%relativeTime%']) { + expect(args[key]).toMatch(/^ { + test('returns ISO date in YYYY-MM-DD format', () => { + mainStub.client.config.timezone = 'UTC'; + expect(helpers.todayInServerTZ()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + test('honors the configured timezone', () => { + // 2024-01-01 00:30 UTC = 2023-12-31 in America/Los_Angeles (UTC-8) + const realDate = global.Date; + global.Date = class extends realDate { + constructor(...args) { + if (args.length === 0) return new realDate('2024-01-01T00:30:00Z'); + return new realDate(...args); + } + + static now() { + return new realDate('2024-01-01T00:30:00Z').getTime(); + } + }; + try { + mainStub.client.config.timezone = 'America/Los_Angeles'; + expect(helpers.todayInServerTZ()).toBe('2023-12-31'); + mainStub.client.config.timezone = 'UTC'; + expect(helpers.todayInServerTZ()).toBe('2024-01-01'); + } finally { + global.Date = realDate; + } + }); +}); + +describe('formatDiscordUserName with addAtToUsernames', () => { + test('prepends @ for new-style users when client setting enabled', () => { + mainStub.client.strings.addAtToUsernames = true; + expect(helpers.formatDiscordUserName({ + discriminator: '0', + username: 'alice' + })).toBe('@alice'); + }); + + test('does not prepend @ for legacy discriminator users', () => { + mainStub.client.strings.addAtToUsernames = true; + expect(helpers.formatDiscordUserName({ + discriminator: '1234', + username: 'alice', + tag: 'alice#1234' + })).toBe('alice#1234'); + }); +}); \ No newline at end of file diff --git a/tests/helpers/dateFormatting.test.js b/tests/helpers/dateFormatting.test.js new file mode 100644 index 00000000..cc4f4b70 --- /dev/null +++ b/tests/helpers/dateFormatting.test.js @@ -0,0 +1,46 @@ +const { + dateToDiscordTimestamp, + formatDate +} = require('../../src/functions/helpers'); + +describe('dateToDiscordTimestamp', () => { + test('renders without style as bare ', () => { + const d = new Date(1700000000_000); + expect(dateToDiscordTimestamp(d)).toBe(''); + }); + + test('appends a style suffix when provided', () => { + const d = new Date(1700000000_000); + expect(dateToDiscordTimestamp(d, 'R')).toBe(''); + expect(dateToDiscordTimestamp(d, 'F')).toBe(''); + expect(dateToDiscordTimestamp(d, 'f')).toBe(''); + }); + + test('floors fractional seconds to integer (toFixed(0) rounds nearest)', () => { + // 1500ms -> rounds to "2" via toFixed(0) + expect(dateToDiscordTimestamp(new Date(1500))).toBe(''); + // 1400ms -> rounds to "1" + expect(dateToDiscordTimestamp(new Date(1400))).toBe(''); + }); + + test('handles epoch zero', () => { + expect(dateToDiscordTimestamp(new Date(0))).toBe(''); + }); +}); + +describe('formatDate', () => { + test('default mode returns two combined Discord timestamps', () => { + const d = new Date(1700000000_000); + expect(formatDate(d)).toBe(' ()'); + }); + + test('skipDiscordFormat mode delegates to the localize stub', () => { + const d = new Date(Date.UTC(2024, 0, 5, 9, 7)); // 2024-01-05 09:07 UTC + const out = formatDate(d, true); + expect(out).toMatch(/^helpers\.timestamp\(/); + // Args contain zero-padded dd, mm, hh, min and a yyyy. + expect(out).toMatch(/yyyy=2024/); + expect(out).toMatch(/mm=01/); + expect(out).toMatch(/dd=05/); + }); +}); \ No newline at end of file diff --git a/tests/helpers/embedType.string.test.js b/tests/helpers/embedType.string.test.js new file mode 100644 index 00000000..9aeeae59 --- /dev/null +++ b/tests/helpers/embedType.string.test.js @@ -0,0 +1,92 @@ +const mainStub = require('../__stubs__/main'); +const {embedType} = require('../../src/functions/helpers'); + +function resetClient() { + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'test-footer', + footerImgUrl: '', + disableFooterTimestamp: false + }; + mainStub.client.scnxSetup = false; + mainStub.client.user = null; + mainStub.client.guild = null; +} + +beforeEach(resetClient); + +describe('embedType - string input', () => { + test('wraps a plain string into content', () => { + const out = embedType('hello world'); + expect(out.content).toBe('hello world'); + }); + + test('emits no embeds/components for string input', () => { + const out = embedType('hi'); + expect(out.embeds).toBeUndefined(); + expect(out.components).toBeUndefined(); + }); + + test('default allowedMentions parses users and roles only', () => { + const out = embedType('ping'); + expect(out.allowedMentions).toEqual({parse: ['users', 'roles']}); + }); + + test('adds everyone to allowedMentions when disableEveryoneProtection is set', () => { + mainStub.client.config.disableEveryoneProtection = true; + const out = embedType('ping'); + expect(out.allowedMentions.parse).toEqual(['users', 'roles', 'everyone']); + }); + + test('preserves explicit allowedMentions from optionsToKeep', () => { + const out = embedType('hi', {}, {allowedMentions: {parse: ['users']}}); + expect(out.allowedMentions).toEqual({parse: ['users']}); + }); + + test('substitutes %placeholder% style args', () => { + const out = embedType('hi %who%', {'%who%': 'Alice'}); + expect(out.content).toBe('hi Alice'); + }); + + test('substitutes multiple placeholders', () => { + const out = embedType('%a%-%b%-%a%', { + '%a%': 'X', + '%b%': 'Y' + }); + expect(out.content).toBe('X-Y-X'); + }); + + test('handles empty string', () => { + const out = embedType(''); + expect(out.content).toBe(''); + }); + + test('handles strings containing already-substituted-looking text', () => { + const out = embedType('the value %unused% stays', {'%name%': 'Bob'}); + expect(out.content).toBe('the value %unused% stays'); + }); + + test('returns the same optionsToKeep object (mutates in place)', () => { + const otk = {someField: 'kept'}; + const out = embedType('hi', {}, otk); + expect(out).toBe(otk); + expect(out.someField).toBe('kept'); + expect(out.content).toBe('hi'); + }); + + test('global args from client.user merge into substitution', () => { + mainStub.client.user = { + id: 'b-1', + tag: 'Bot#0000', + username: 'b', + displayName: 'Bot', + displayAvatarURL: () => 'https://x/a.png', + toString: () => '<@b-1>' + }; + const out = embedType('Hi from %botName%'); + expect(out.content).toBe('Hi from Bot'); + }); +}); \ No newline at end of file diff --git a/tests/helpers/embedType.v2.test.js b/tests/helpers/embedType.v2.test.js new file mode 100644 index 00000000..4e52c34f --- /dev/null +++ b/tests/helpers/embedType.v2.test.js @@ -0,0 +1,410 @@ +const mainStub = require('../__stubs__/main'); +const { + embedType, + embedTypeV2 +} = require('../../src/functions/helpers'); + +function resetClient() { + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'test-footer', + footerImgUrl: '', + disableFooterTimestamp: false + }; + mainStub.client.scnxSetup = false; + mainStub.client.user = null; + mainStub.client.guild = null; +} + +beforeEach(resetClient); + +describe('embedType v2 - dispatch', () => { + test('default _schema (undefined) routes through v2 path', () => { + const out = embedType({title: 'Hello'}); + expect(out.embeds).toHaveLength(1); + expect(out.embeds[0].data.title).toBe('Hello'); + }); + + test('explicit _schema "v2" routes through v2 path', () => { + const out = embedType({ + _schema: 'v2', + title: 'Hi' + }); + expect(out.embeds[0].data.title).toBe('Hi'); + }); +}); + +describe('embedType v2 - empty / minimal input', () => { + test('completely empty object emits no embeds', () => { + const out = embedType({}); + expect(out.embeds).toEqual([]); + }); + + test('input with only a message (content) emits no embed', () => { + const out = embedType({message: 'just a content'}); + expect(out.embeds).toEqual([]); + expect(out.content).toBe('just a content'); + }); + + test('input with only image still produces an embed', () => { + const out = embedType({image: 'https://x/i.png'}); + expect(out.embeds).toHaveLength(1); + expect(out.embeds[0].data.image.url).toBe('https://x/i.png'); + }); +}); + +describe('embedType v2 - title and description', () => { + test('renders both title and description', () => { + const out = embedType({ + title: 'T', + description: 'D' + }); + expect(out.embeds[0].data.title).toBe('T'); + expect(out.embeds[0].data.description).toBe('D'); + }); + + test('truncates title over 256 chars', () => { + const out = embedType({title: 'x'.repeat(500)}); + expect(out.embeds[0].data.title).toHaveLength(256); + expect(out.embeds[0].data.title.endsWith('...')).toBe(true); + }); + + test('truncates description over 4096 chars', () => { + const out = embedType({ + title: 't', + description: 'y'.repeat(5000) + }); + expect(out.embeds[0].data.description).toHaveLength(4096); + }); + + test('substitutes args into title and description', () => { + const out = embedType({ + title: 'Hi %name%', + description: 'Welcome %name%' + }, {'%name%': 'Alice'}); + expect(out.embeds[0].data.title).toBe('Hi Alice'); + expect(out.embeds[0].data.description).toBe('Welcome Alice'); + }); +}); + +describe('embedType v2 - color', () => { + test('accepts named color', () => { + const out = embedType({ + title: 't', + color: 'RED' + }); + expect(out.embeds[0].data.color).toBe(0xE74C3C); + }); + + test('accepts hex string with hash', () => { + const out = embedType({ + title: 't', + color: '#abcdef' + }); + expect(out.embeds[0].data.color).toBe(0xabcdef); + }); + + test('accepts bare hex string', () => { + const out = embedType({ + title: 't', + color: 'ff00ff' + }); + expect(out.embeds[0].data.color).toBe(0xff00ff); + }); + + test('accepts numeric color', () => { + const out = embedType({ + title: 't', + color: 0x123456 + }); + expect(out.embeds[0].data.color).toBe(0x123456); + }); +}); + +describe('embedType v2 - URL, image, thumbnail', () => { + test('sets URL when provided', () => { + const out = embedType({ + title: 't', + url: 'https://example.com' + }); + expect(out.embeds[0].data.url).toBe('https://example.com'); + }); + + test('skips URL when whitespace-only', () => { + const out = embedType({ + title: 't', + url: ' ' + }); + expect(out.embeds[0].data.url).toBeUndefined(); + }); + + test('sets image when provided', () => { + const out = embedType({ + title: 't', + image: 'https://x/i.png' + }); + expect(out.embeds[0].data.image.url).toBe('https://x/i.png'); + }); + + test('sets thumbnail when provided', () => { + const out = embedType({ + title: 't', + thumbnail: 'https://x/t.png' + }); + expect(out.embeds[0].data.thumbnail.url).toBe('https://x/t.png'); + }); + + test('substitutes args in image/thumbnail/url', () => { + const out = embedType( + { + title: 't', + url: 'https://%host%/x', + image: 'https://%host%/i', + thumbnail: 'https://%host%/t' + }, + {'%host%': 'example.com'} + ); + expect(out.embeds[0].data.url).toBe('https://example.com/x'); + expect(out.embeds[0].data.image.url).toBe('https://example.com/i'); + expect(out.embeds[0].data.thumbnail.url).toBe('https://example.com/t'); + }); +}); + +describe('embedType v2 - author', () => { + test('sets author name when present', () => { + const out = embedType({ + title: 't', + author: {name: 'Alice'} + }); + expect(out.embeds[0].data.author.name).toBe('Alice'); + }); + + test('sets author iconURL from img field', () => { + const out = embedType({ + title: 't', + author: { + name: 'Alice', + img: 'https://x/a.png' + } + }); + expect(out.embeds[0].data.author.icon_url).toBe('https://x/a.png'); + }); + + test('skips author iconURL when img is empty', () => { + const out = embedType({ + title: 't', + author: { + name: 'Alice', + img: '' + } + }); + expect(out.embeds[0].data.author.icon_url).toBeNull(); + }); + + test('truncates author name over 256 chars', () => { + const out = embedType({ + title: 't', + author: {name: 'a'.repeat(500)} + }); + expect(out.embeds[0].data.author.name).toHaveLength(256); + }); + + test('skips author block when name missing', () => { + const out = embedType({ + title: 't', + author: {img: 'https://x/a.png'} + }); + expect(out.embeds[0].data.author).toBeUndefined(); + }); + + test('handles non-object author gracefully', () => { + const out = embedType({ + title: 't', + author: 'not an object' + }); + expect(out.embeds[0].data.author).toBeUndefined(); + }); +}); + +describe('embedType v2 - fields', () => { + test('emits a single field', () => { + const out = embedType({ + title: 't', + fields: [{ + name: 'F', + value: 'V', + inline: true + }] + }); + expect(out.embeds[0].data.fields).toEqual([{ + name: 'F', + value: 'V', + inline: true + }]); + }); + + test('emits multiple fields preserving order', () => { + const out = embedType({ + title: 't', + fields: [ + { + name: 'A', + value: '1' + }, + { + name: 'B', + value: '2' + }, + { + name: 'C', + value: '3' + } + ] + }); + expect(out.embeds[0].data.fields.map((f) => f.name)).toEqual(['A', 'B', 'C']); + }); + + test('truncates field name to 256 and value to 1024', () => { + const out = embedType({ + title: 't', + fields: [{ + name: 'a'.repeat(500), + value: 'b'.repeat(2000) + }] + }); + expect(out.embeds[0].data.fields[0].name).toHaveLength(256); + expect(out.embeds[0].data.fields[0].value).toHaveLength(1024); + }); + + test('substitutes args in field name and value', () => { + const out = embedType({ + title: 't', + fields: [{ + name: 'Name: %x%', + value: 'Value: %x%' + }] + }, {'%x%': '42'}); + expect(out.embeds[0].data.fields[0]).toMatchObject({ + name: 'Name: 42', + value: 'Value: 42' + }); + }); + + test('non-object fields value is ignored without throwing', () => { + expect(() => embedType({ + title: 't', + fields: 'not an array' + })).not.toThrow(); + }); +}); + +describe('embedType v2 - footer', () => { + test('uses input footer text', () => { + const out = embedType({ + title: 't', + footer: 'custom footer' + }); + expect(out.embeds[0].data.footer.text).toBe('custom footer'); + }); + + test('falls back to client.strings.footer when no input footer', () => { + mainStub.client.strings.footer = 'default-footer'; + const out = embedType({title: 't'}); + expect(out.embeds[0].data.footer.text).toBe('default-footer'); + }); + + test('uses footerImgUrl from input', () => { + const out = embedType({ + title: 't', + footer: 'x', + footerImgUrl: 'https://x/icon.png' + }); + expect(out.embeds[0].data.footer.icon_url).toBe('https://x/icon.png'); + }); + + test('falls back to client.strings.footerImgUrl', () => { + mainStub.client.strings.footerImgUrl = 'https://x/default-icon.png'; + const out = embedType({ + title: 't', + footer: 'x' + }); + expect(out.embeds[0].data.footer.icon_url).toBe('https://x/default-icon.png'); + }); + + test('skips footer when both input and client.strings.footer are empty', () => { + mainStub.client.strings.footer = ''; + const out = embedType({title: 't'}); + expect(out.embeds[0].data.footer).toBeUndefined(); + }); + + test('substitutes args in footer text', () => { + const out = embedType({ + title: 't', + footer: 'by %name%' + }, {'%name%': 'Bob'}); + expect(out.embeds[0].data.footer.text).toBe('by Bob'); + }); +}); + +describe('embedType v2 - timestamp', () => { + test('sets a timestamp by default', () => { + const out = embedType({title: 't'}); + expect(out.embeds[0].data.timestamp).toBeDefined(); + expect(typeof out.embeds[0].data.timestamp).toBe('string'); + }); + + test('omits timestamp when disableFooterTimestamp set', () => { + mainStub.client.strings.disableFooterTimestamp = true; + const out = embedType({title: 't'}); + expect(out.embeds[0].data.timestamp).toBeUndefined(); + }); + + test('uses explicit embedTimestamp Date override', () => { + const ts = new Date('2024-06-01T12:00:00Z'); + const out = embedType({ + title: 't', + embedTimestamp: ts + }); + expect(new Date(out.embeds[0].data.timestamp).getTime()).toBe(ts.getTime()); + }); +}); + +describe('embedType v2 - message content', () => { + test('sets content from input.message', () => { + const out = embedType({ + title: 't', + message: 'side message' + }); + expect(out.content).toBe('side message'); + }); + + test('content is null when message missing', () => { + const out = embedType({title: 't'}); + expect(out.content).toBeNull(); + }); + + test('substitutes args in message', () => { + const out = embedType({ + title: 't', + message: 'hi %who%' + }, {'%who%': 'world'}); + expect(out.content).toBe('hi world'); + }); +}); + +describe('embedTypeV2 (async wrapper)', () => { + test('passes through identical to embedType for non-dynamic input', async () => { + const sync = embedType({ + title: 'sync', + description: 'd' + }); + const async_ = await embedTypeV2({ + title: 'sync', + description: 'd' + }, {}, {}); + expect(async_.embeds[0].data.title).toBe(sync.embeds[0].data.title); + }); +}); \ No newline at end of file diff --git a/tests/helpers/embedType.v3.test.js b/tests/helpers/embedType.v3.test.js new file mode 100644 index 00000000..e2748de1 --- /dev/null +++ b/tests/helpers/embedType.v3.test.js @@ -0,0 +1,541 @@ +const mainStub = require('../__stubs__/main'); +const {embedType} = require('../../src/functions/helpers'); + +function resetClient() { + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'default-footer', + footerImgUrl: '', + disableFooterTimestamp: false + }; + mainStub.client.scnxSetup = false; + mainStub.client.user = null; + mainStub.client.guild = null; +} + +beforeEach(resetClient); + +describe('embedType v3 - dispatch', () => { + test('input with _schema "v3" routes through legacy embeds[] path', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{title: 'v3'}] + }); + expect(out.embeds).toHaveLength(1); + expect(out.embeds[0].data.title).toBe('v3'); + }); + + test('any non-v2/non-v4 _schema falls through legacy path', () => { + const out = embedType({ + _schema: 'legacy', + embeds: [{title: 'L'}] + }); + expect(out.embeds[0].data.title).toBe('L'); + }); +}); + +describe('embedType v3 - embeds array', () => { + test('emits no embeds when embeds[] is empty', () => { + const out = embedType({ + _schema: 'v3', + embeds: [] + }); + expect(out.embeds).toEqual([]); + }); + + test('emits no embeds when embeds is absent', () => { + const out = embedType({_schema: 'v3'}); + expect(out.embeds).toEqual([]); + }); + + test('emits multiple embeds preserving order', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{title: 'A'}, {title: 'B'}, {title: 'C'}] + }); + expect(out.embeds.map((e) => e.data.title)).toEqual(['A', 'B', 'C']); + }); + + test('handles 10 embeds (V3 spec max)', () => { + const embeds = Array.from({length: 10}, (_, i) => ({title: `E${i}`})); + const out = embedType({ + _schema: 'v3', + embeds + }); + expect(out.embeds).toHaveLength(10); + }); +}); + +describe('embedType v3 - embed fields', () => { + test('renders title and description', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 'T', + description: 'D' + }] + }); + expect(out.embeds[0].data.title).toBe('T'); + expect(out.embeds[0].data.description).toBe('D'); + }); + + test('truncates title at 256 chars', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{title: 'x'.repeat(500)}] + }); + expect(out.embeds[0].data.title).toHaveLength(256); + }); + + test('truncates description at 4096 chars', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{description: 'y'.repeat(5000)}] + }); + expect(out.embeds[0].data.description).toHaveLength(4096); + }); + + test('renders color from all formats', () => { + const named = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + color: 'BLURPLE' + }] + }); + expect(named.embeds[0].data.color).toBe(0x5865F2); + const hex = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + color: '#ffaa00' + }] + }); + expect(hex.embeds[0].data.color).toBe(0xffaa00); + const num = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + color: 0x336699 + }] + }); + expect(num.embeds[0].data.color).toBe(0x336699); + }); + + test('renders thumbnailURL', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + thumbnailURL: 'https://x/t.png' + }] + }); + expect(out.embeds[0].data.thumbnail.url).toBe('https://x/t.png'); + }); + + test('renders imageURL', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + imageURL: 'https://x/i.png' + }] + }); + expect(out.embeds[0].data.image.url).toBe('https://x/i.png'); + }); + + test('substitutes args in thumbnailURL and imageURL', () => { + const out = embedType( + { + _schema: 'v3', + embeds: [{ + title: 't', + thumbnailURL: 'https://%h%/t', + imageURL: 'https://%h%/i' + }] + }, + {'%h%': 'example.com'} + ); + expect(out.embeds[0].data.thumbnail.url).toBe('https://example.com/t'); + expect(out.embeds[0].data.image.url).toBe('https://example.com/i'); + }); + + test('skips thumbnail/image when value is empty or whitespace', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + thumbnailURL: ' ', + imageURL: '' + }] + }); + expect(out.embeds[0].data.thumbnail).toBeNull(); + expect(out.embeds[0].data.image).toBeNull(); + }); +}); + +describe('embedType v3 - footer', () => { + test('renders footer text and icon', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + footer: { + text: 'F', + iconURL: 'https://x/i.png' + } + }] + }); + expect(out.embeds[0].data.footer.text).toBe('F'); + expect(out.embeds[0].data.footer.icon_url).toBe('https://x/i.png'); + }); + + test('falls back to client.strings.footer when text empty', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + footer: {text: ''} + }] + }); + expect(out.embeds[0].data.footer.text).toBe('default-footer'); + }); + + test('disabled footer is omitted entirely', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + footer: { + disabled: true, + text: 'ignored' + } + }] + }); + expect(out.embeds[0].data.footer).toBeNull(); + }); + + test('disabled footer also disables timestamp', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + footer: {disabled: true} + }] + }); + expect(out.embeds[0].data.timestamp).toBeNull(); + }); + + test('hideTime suppresses timestamp but keeps footer', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + footer: { + text: 'F', + hideTime: true + } + }] + }); + expect(out.embeds[0].data.footer.text).toBe('F'); + expect(out.embeds[0].data.timestamp).toBeNull(); + }); + + test('substitutes args in footer text', () => { + const out = embedType( + { + _schema: 'v3', + embeds: [{ + title: 't', + footer: {text: 'by %name%'} + }] + }, + {'%name%': 'Carol'} + ); + expect(out.embeds[0].data.footer.text).toBe('by Carol'); + }); +}); + +describe('embedType v3 - author', () => { + test('renders full author with name, imageURL, url', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + author: { + name: 'Alice', + imageURL: 'https://x/a.png', + url: 'https://example.com/u' + } + }] + }); + expect(out.embeds[0].data.author.name).toBe('Alice'); + expect(out.embeds[0].data.author.icon_url).toBe('https://x/a.png'); + expect(out.embeds[0].data.author.url).toBe('https://example.com/u'); + }); + + test('omits author when name is missing', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + author: {imageURL: 'https://x/a.png'} + }] + }); + expect(out.embeds[0].data.author).toBeNull(); + }); + + test('skips iconURL when empty or whitespace', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + author: { + name: 'A', + imageURL: ' ' + } + }] + }); + expect(out.embeds[0].data.author.icon_url).toBeNull(); + }); + + test('truncates name at 256 chars', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + author: {name: 'x'.repeat(500)} + }] + }); + expect(out.embeds[0].data.author.name).toHaveLength(256); + }); + + test('substitutes args in author name', () => { + const out = embedType( + { + _schema: 'v3', + embeds: [{ + title: 't', + author: {name: 'Hello %who%'} + }] + }, + {'%who%': 'World'} + ); + expect(out.embeds[0].data.author.name).toBe('Hello World'); + }); +}); + +describe('embedType v3 - embed fields', () => { + test('emits single field with default inline false', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + fields: [{ + name: 'F', + value: 'V' + }] + }] + }); + expect(out.embeds[0].data.fields).toEqual([{ + name: 'F', + value: 'V' + }]); + }); + + test('emits inline field correctly', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + fields: [{ + name: 'F', + value: 'V', + inline: true + }] + }] + }); + expect(out.embeds[0].data.fields[0].inline).toBe(true); + }); + + test('uses zero-width space for empty field name/value', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + fields: [{ + name: '', + value: '' + }] + }] + }); + expect(out.embeds[0].data.fields[0].name).toBe('​'); + expect(out.embeds[0].data.fields[0].value).toBe('​'); + }); + + test('truncates field name at 256 and value at 1024', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + fields: [{ + name: 'a'.repeat(500), + value: 'b'.repeat(2000) + }] + }] + }); + expect(out.embeds[0].data.fields[0].name).toHaveLength(256); + expect(out.embeds[0].data.fields[0].value).toHaveLength(1024); + }); + + test('renders multiple fields preserving order', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{ + title: 't', + fields: [{ + name: 'a', + value: '1' + }, { + name: 'b', + value: '2' + }, { + name: 'c', + value: '3' + }] + }] + }); + expect(out.embeds[0].data.fields.map((f) => f.name)).toEqual(['a', 'b', 'c']); + }); +}); + +describe('embedType v3 - attachmentURLs', () => { + test('appends attachmentURLs as files', () => { + const out = embedType({ + _schema: 'v3', + embeds: [], + attachmentURLs: ['https://x/a.png', 'https://x/b.png'] + }); + expect(out.files).toHaveLength(2); + expect(out.files[0]).toEqual({attachment: 'https://x/a.png'}); + expect(out.files[1]).toEqual({attachment: 'https://x/b.png'}); + }); + + test('filters out empty and whitespace-only URLs', () => { + const out = embedType({ + _schema: 'v3', + embeds: [], + attachmentURLs: ['', ' ', 'https://x/c.png', null] + }); + expect(out.files).toHaveLength(1); + expect(out.files[0].attachment).toBe('https://x/c.png'); + }); + + test('preserves pre-existing optionsToKeep.files', () => { + const out = embedType( + { + _schema: 'v3', + embeds: [], + attachmentURLs: ['https://x/new.png'] + }, + {}, + {files: [{attachment: 'https://x/existing.png'}]} + ); + expect(out.files).toHaveLength(2); + expect(out.files[0].attachment).toBe('https://x/existing.png'); + expect(out.files[1].attachment).toBe('https://x/new.png'); + }); + + test('treats missing attachmentURLs as empty', () => { + const out = embedType({ + _schema: 'v3', + embeds: [] + }); + expect(out.files).toEqual([]); + }); +}); + +describe('embedType v3 - content', () => { + test('sets content from input.content field', () => { + const out = embedType({ + _schema: 'v3', + embeds: [], + content: 'top-level content' + }); + expect(out.content).toBe('top-level content'); + }); + + test('content with args is substituted', () => { + const out = embedType({ + _schema: 'v3', + embeds: [], + content: 'hello %who%' + }, {'%who%': 'Dave'}); + expect(out.content).toBe('hello Dave'); + }); + + test('returns null content when missing and no message', () => { + const out = embedType({ + _schema: 'v3', + embeds: [] + }); + expect(out.content).toBeNull(); + }); + + test('preserves existing optionsToKeep.content over input.content', () => { + const out = embedType( + { + _schema: 'v3', + embeds: [], + content: 'from input' + }, + {}, + {content: 'from optionsToKeep'} + ); + expect(out.content).toBe('from optionsToKeep'); + }); +}); + +describe('embedType v3 - timestamp', () => { + test('sets a timestamp by default', () => { + const out = embedType({ + _schema: 'v3', + embeds: [{title: 't'}] + }); + expect(out.embeds[0].data.timestamp).toBeDefined(); + }); + + test('global disableFooterTimestamp suppresses timestamp', () => { + mainStub.client.strings.disableFooterTimestamp = true; + const out = embedType({ + _schema: 'v3', + embeds: [{title: 't'}] + }); + expect(out.embeds[0].data.timestamp).toBeNull(); + }); +}); + +describe('embedType v3 - allowedMentions', () => { + test('default allowedMentions includes users and roles', () => { + const out = embedType({ + _schema: 'v3', + embeds: [] + }); + expect(out.allowedMentions.parse).toEqual(['users', 'roles']); + }); + + test('preserves optionsToKeep allowedMentions', () => { + const out = embedType( + { + _schema: 'v3', + embeds: [] + }, + {}, + {allowedMentions: {parse: []}} + ); + expect(out.allowedMentions).toEqual({parse: []}); + }); +}); \ No newline at end of file diff --git a/tests/helpers/embedType.v4.test.js b/tests/helpers/embedType.v4.test.js new file mode 100644 index 00000000..5a6d0301 --- /dev/null +++ b/tests/helpers/embedType.v4.test.js @@ -0,0 +1,1208 @@ +const mainStub = require('../__stubs__/main'); +const { + embedType, + __test +} = require('../../src/functions/helpers'); +const { + buildV4Button, + buildV4StringSelect, + buildV4Component +} = __test; +const {ButtonStyle} = require('discord.js'); + +function resetClient() { + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'test-footer', + footerImgUrl: '', + disableFooterTimestamp: false + }; + mainStub.client.scnxSetup = false; + mainStub.client.user = null; + mainStub.client.guild = null; + mainStub.client.logger = { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + log: jest.fn() + }; +} + +beforeEach(resetClient); + +describe('embedType v4 - dispatch', () => { + test('sets IsComponentsV2 flag and clears content/embeds', () => { + const out = embedType({ + _schema: 'v4', + components: [] + }); + expect(out.flags).toBeGreaterThan(0); + expect(out.content).toBeNull(); + expect(out.embeds).toEqual([]); + }); + + test('preserves existing optionsToKeep.flags via bitwise OR', () => { + const out = embedType({ + _schema: 'v4', + components: [] + }, {}, {flags: 1}); + expect(out.flags & 1).toBe(1); + }); + + test('coerces string flags to number before OR', () => { + const out = embedType({ + _schema: 'v4', + components: [] + }, {}, {flags: '2'}); + expect(typeof out.flags).toBe('number'); + }); + + test('preserves existing components by appending at the end', () => { + const existing = {marker: 'kept'}; + const out = embedType({ + _schema: 'v4', + components: [] + }, {}, {components: [existing]}); + expect(out.components.at(-1)).toEqual(existing); + }); + + test('appends mergeComponentsRows in order', () => { + const row1 = {marker: 'row1'}; + const row2 = {marker: 'row2'}; + const out = embedType({ + _schema: 'v4', + components: [] + }, {}, {}, [row1, row2]); + expect(out.components).toContain(row1); + expect(out.components).toContain(row2); + }); + + test('logs error and continues when a top-level component build throws', () => { + const out = embedType({ + _schema: 'v4', + components: [{type: 'bogus'}] + }); + expect(out.components).toEqual([]); + }); + + test('null/undefined component returns null from builder', () => { + expect(buildV4Component(null, {})).toBeNull(); + expect(buildV4Component(undefined, {})).toBeNull(); + expect(buildV4Component({}, {})).toBeNull(); + }); +}); + +describe('embedType v4 - TextDisplay (type 10)', () => { + test('renders TextDisplay with content', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 10, + content: 'Hello world' + }] + }); + expect(out.components).toHaveLength(1); + expect(out.components[0].data.content).toBe('Hello world'); + }); + + test('substitutes args in content', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 10, + content: 'Hi %name%' + }] + }, {'%name%': 'Eve'}); + expect(out.components[0].data.content).toBe('Hi Eve'); + }); + + test('truncates content over 4000 chars', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 10, + content: 'x'.repeat(5000) + }] + }); + expect(out.components[0].data.content).toHaveLength(4000); + }); + + test('skips empty content', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 10, + content: '' + }] + }); + expect(out.components).toEqual([]); + }); + + test('skips missing content', () => { + const out = embedType({ + _schema: 'v4', + components: [{type: 10}] + }); + expect(out.components).toEqual([]); + }); +}); + +describe('embedType v4 - Separator (type 14)', () => { + test('renders a Separator with defaults', () => { + const out = embedType({ + _schema: 'v4', + components: [{type: 14}] + }); + expect(out.components).toHaveLength(1); + }); + + test('honors divider true', () => { + const sep = buildV4Component({ + type: 14, + divider: true + }, {}); + expect(sep.data.divider).toBe(true); + }); + + test('honors divider false', () => { + const sep = buildV4Component({ + type: 14, + divider: false + }, {}); + expect(sep.data.divider).toBe(false); + }); + + test('spacing 2 maps to Large', () => { + const sep = buildV4Component({ + type: 14, + spacing: 2 + }, {}); + expect(sep.data.spacing).toBe(2); + }); + + test('default spacing is Small (1)', () => { + const sep = buildV4Component({type: 14}, {}); + expect(sep.data.spacing).toBe(1); + }); +}); + +describe('embedType v4 - MediaGallery (type 12)', () => { + test('renders a gallery with one item', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 12, + items: [{media: {url: 'https://x/a.png'}}] + }] + }); + expect(out.components).toHaveLength(1); + expect(out.components[0].items).toHaveLength(1); + }); + + test('skips items without media.url', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 12, + items: [{media: {}}, {media: {url: 'https://x/a.png'}}] + }] + }); + expect(out.components[0].items).toHaveLength(1); + }); + + test('returns null when items array is empty', () => { + const result = buildV4Component({ + type: 12, + items: [] + }, {}); + expect(result).toBeNull(); + }); + + test('returns null when items is missing', () => { + const result = buildV4Component({type: 12}, {}); + expect(result).toBeNull(); + }); + + test('renders item description and spoiler flag', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 12, + items: [{ + media: {url: 'https://x/i.png'}, + description: 'alt text', + spoiler: true + }] + }] + }); + const item = out.components[0].items[0].data; + expect(item.description).toBe('alt text'); + expect(item.spoiler).toBe(true); + }); + + test('substitutes args in item url and description', () => { + const out = embedType( + { + _schema: 'v4', + components: [{ + type: 12, + items: [{ + media: {url: 'https://%h%/i.png'}, + description: 'image of %who%' + }] + }] + }, + { + '%h%': 'example.com', + '%who%': 'me' + } + ); + const item = out.components[0].items[0].data; + expect(item.media.url).toBe('https://example.com/i.png'); + expect(item.description).toBe('image of me'); + }); + + test('skips items whose substituted URL is empty', () => { + const out = embedType( + { + _schema: 'v4', + components: [{ + type: 12, + items: [{media: {url: '%missing%'}}] + }] + }, + {} + ); + expect(out.components).toEqual([]); + }); +}); + +describe('embedType v4 - File (type 13)', () => { + test('renders a File component with attachment:// URL', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 13, + file: {url: 'attachment://doc.pdf'} + }] + }); + expect(out.components).toHaveLength(1); + }); + + test('returns null when file.url missing', () => { + const result = buildV4Component({ + type: 13, + file: {} + }, {}); + expect(result).toBeNull(); + }); + + test('returns null when file is missing', () => { + const result = buildV4Component({type: 13}, {}); + expect(result).toBeNull(); + }); + + test('honors spoiler flag', () => { + const file = buildV4Component({ + type: 13, + file: {url: 'attachment://a.pdf'}, + spoiler: true + }, {}); + expect(file.data.spoiler).toBe(true); + }); + + test('substitutes args in file URL', () => { + const file = buildV4Component({ + type: 13, + file: {url: 'attachment://%name%.pdf'} + }, {'%name%': 'doc'}); + expect(file.data.file.url).toBe('attachment://doc.pdf'); + }); + + test('logs error and returns null when URL fails discord.js validation', () => { + // FileBuilder rejects any scheme other than attachment://. Builder error is swallowed + // by the try/catch in buildV4Component which logs and returns null. + const result = buildV4Component({ + type: 13, + file: {url: 'https://x/a.pdf'} + }, {}); + expect(result).toBeNull(); + }); +}); + +describe('embedType v4 - ActionRow (type 1) with buttons', () => { + test('renders a row with one button', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 1, + components: [{ + type: 2, + style: 1, + label: 'Click', + custom_id: 'x' + }] + }] + }); + expect(out.components).toHaveLength(1); + }); + + test('caps row at 5 buttons (slices excess)', () => { + const buttons = Array.from({length: 8}, (_, i) => ({ + type: 2, + style: 1, + label: `B${i}`, + custom_id: `b-${i}` + })); + const row = buildV4Component({ + type: 1, + components: buttons + }, {}); + expect(row.components).toHaveLength(5); + }); + + test('returns null when row has no valid buttons', () => { + const row = buildV4Component({ + type: 1, + components: [{ + type: 2, + style: 1 + }] // no label, no emoji -> invalid + }, {}); + expect(row).toBeNull(); + }); + + test('returns null when components array missing or empty', () => { + expect(buildV4Component({type: 1}, {})).toBeNull(); + expect(buildV4Component({ + type: 1, + components: [] + }, {})).toBeNull(); + }); + + test('skips non-button (type !== 2) entries silently', () => { + const row = buildV4Component({ + type: 1, + components: [ + { + type: 2, + style: 1, + label: 'OK', + custom_id: 'x' + }, + { + type: 99, + label: 'ignored' + } + ] + }, {}); + expect(row.components).toHaveLength(1); + }); +}); + +describe('embedType v4 - ActionRow with StringSelect', () => { + test('first child of type 3 routes to StringSelect builder', () => { + const row = buildV4Component({ + type: 1, + components: [{ + type: 3, + custom_id: 'sel', + options: [{ + label: 'A', + value: 'a' + }, { + label: 'B', + value: 'b' + }] + }] + }, {}); + expect(row).toBeTruthy(); + expect(row.components).toHaveLength(1); + }); + + test('returns null when select has empty options', () => { + const row = buildV4Component({ + type: 1, + components: [{ + type: 3, + custom_id: 's', + options: [] + }] + }, {}); + expect(row).toBeNull(); + }); +}); + +describe('buildV4Button', () => { + test('renders Primary style button with label and custom_id', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'Go', + custom_id: 'go-btn' + }, {}); + expect(btn.data.style).toBe(ButtonStyle.Primary); + expect(btn.data.label).toBe('Go'); + expect(btn.data.custom_id).toBe('go-btn'); + }); + + test('renders Secondary by default when style missing', () => { + const btn = buildV4Button({ + type: 2, + label: 'X', + custom_id: 'x' + }, {}); + expect(btn.data.style).toBe(ButtonStyle.Secondary); + }); + + test('truncates label at 80 chars', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'a'.repeat(100), + custom_id: 'x' + }, {}); + expect(btn.data.label).toHaveLength(80); + }); + + test('substitutes args in label', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'Hi %name%', + custom_id: 'x' + }, {'%name%': 'Eve'}); + expect(btn.data.label).toBe('Hi Eve'); + }); + + test('sets emoji when valid', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'Like', + emoji: '👍' + }, {}); + expect(btn.data.emoji).toMatchObject({name: '👍'}); + }); + + test('skips emoji string "null"', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'X', + emoji: 'null' + }, {}); + expect(btn.data.emoji).toBeUndefined(); + }); + + test('skips empty emoji', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'X', + emoji: '' + }, {}); + expect(btn.data.emoji).toBeUndefined(); + }); + + test('returns null when no label and no emoji', () => { + expect(buildV4Button({ + type: 2, + style: 1 + }, {})).toBeNull(); + }); + + test('emoji-only button (no label) is valid', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + emoji: '⭐', + custom_id: 'star' + }, {}); + expect(btn).toBeTruthy(); + expect(btn.data.label).toBeUndefined(); + }); + + test('disabled flag flows through', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'X', + custom_id: 'x', + disabled: true + }, {}); + expect(btn.data.disabled).toBe(true); + }); + + test('style 5 (link) requires url and skips if missing', () => { + expect(buildV4Button({ + type: 2, + style: 5, + label: 'L' + }, {})).toBeNull(); + }); + + test('style 5 (link) with url renders as Link button', () => { + const btn = buildV4Button({ + type: 2, + style: 5, + label: 'L', + url: 'https://example.com' + }, {}); + expect(btn.data.style).toBe(ButtonStyle.Link); + expect(btn.data.url).toBe('https://example.com'); + }); + + test('substitutes args in URL', () => { + const btn = buildV4Button( + { + type: 2, + style: 5, + label: 'L', + url: 'https://%host%' + }, + {'%host%': 'example.org'} + ); + expect(btn.data.url).toBe('https://example.org'); + }); + + test('scnx_action linkButton overrides style', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'Link', + url: 'https://x.io', + scnx_action: {type: 'linkButton'} + }, {}); + expect(btn.data.style).toBe(ButtonStyle.Link); + }); + + test('scnx_action linkButton returns null when url empty', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'Link', + scnx_action: {type: 'linkButton'} + }, {}); + expect(btn).toBeNull(); + }); + + test('scnx_action roleButton with add → srb-a- custom_id', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'R', + scnx_action: { + type: 'roleButton', + id: 'r-1', + action: 'add' + } + }, {}); + expect(btn.data.custom_id).toBe('srb-a-r-1'); + }); + + test('scnx_action roleButton with remove → srb-r-', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'R', + scnx_action: { + type: 'roleButton', + id: 'r-2', + action: 'remove' + } + }, {}); + expect(btn.data.custom_id).toBe('srb-r-r-2'); + }); + + test('scnx_action roleButton with toggle (default) → srb-t-', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'R', + scnx_action: { + type: 'roleButton', + id: 'r-3' + } + }, {}); + expect(btn.data.custom_id).toBe('srb-t-r-3'); + }); + + test('scnx_action customCommandButton → cc-', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'CC', + scnx_action: { + type: 'customCommandButton', + id: 'cmd-7' + } + }, {}); + expect(btn.data.custom_id).toBe('cc-cmd-7'); + }); + + test('scnx_action disabledButton forces disabled and unique id', () => { + const btn = buildV4Button({ + type: 2, + style: 1, + label: 'D', + scnx_action: {type: 'disabledButton'} + }, {}); + expect(btn.data.disabled).toBe(true); + expect(btn.data.custom_id).toMatch(/^disabled-/); + }); +}); + +describe('buildV4StringSelect', () => { + test('builds a basic select with options', () => { + const sel = buildV4StringSelect({ + type: 3, + custom_id: 's', + options: [{ + label: 'A', + value: 'a' + }, { + label: 'B', + value: 'b' + }] + }, {}, { + roleSelect: 0, + ccSelect: 0 + }); + expect(sel.data.custom_id).toBe('s'); + expect(sel.options).toHaveLength(2); + }); + + test('returns null when options missing or empty', () => { + expect(buildV4StringSelect({ + type: 3, + custom_id: 's' + }, {}, {})).toBeNull(); + expect(buildV4StringSelect({ + type: 3, + custom_id: 's', + options: [] + }, {}, {})).toBeNull(); + }); + + test('skips options without a value', () => { + const sel = buildV4StringSelect({ + type: 3, + custom_id: 's', + options: [{ + label: 'A', + value: 'a' + }, {label: 'B'}] + }, {}, {}); + expect(sel.options).toHaveLength(1); + }); + + test('skips options whose label resolves empty', () => { + const sel = buildV4StringSelect({ + type: 3, + custom_id: 's', + options: [{ + label: 'A', + value: 'a' + }, { + label: '', + value: 'b' + }] + }, {}, {}); + expect(sel.options).toHaveLength(1); + }); + + test('truncates option labels at 100 and descriptions at 100', () => { + const sel = buildV4StringSelect({ + type: 3, + custom_id: 's', + options: [{ + label: 'x'.repeat(200), + value: 'v', + description: 'd'.repeat(200) + }] + }, {}, {}); + expect(sel.options[0].data.label).toHaveLength(100); + expect(sel.options[0].data.description).toHaveLength(100); + }); + + test('truncates placeholder at 150', () => { + const sel = buildV4StringSelect({ + type: 3, + custom_id: 's', + placeholder: 'p'.repeat(200), + options: [{ + label: 'A', + value: 'a' + }] + }, {}, {}); + expect(sel.data.placeholder).toHaveLength(150); + }); + + test('honors min_values and max_values within bounds', () => { + const sel = buildV4StringSelect({ + type: 3, + custom_id: 's', + min_values: 1, + max_values: 2, + options: [{ + label: 'A', + value: 'a' + }, { + label: 'B', + value: 'b' + }, { + label: 'C', + value: 'c' + }] + }, {}, {}); + expect(sel.data.min_values).toBe(1); + expect(sel.data.max_values).toBe(2); + }); + + test('clamps max_values to options length', () => { + const sel = buildV4StringSelect({ + type: 3, + custom_id: 's', + max_values: 99, + options: [{ + label: 'A', + value: 'a' + }, { + label: 'B', + value: 'b' + }] + }, {}, {}); + expect(sel.data.max_values).toBeLessThanOrEqual(2); + }); + + test('scnx_action roleElement uses incremental counter for custom_id', () => { + const counters = { + roleSelect: 0, + ccSelect: 0 + }; + const a = buildV4StringSelect({ + type: 3, + scnx_action: {type: 'roleElement'}, + options: [{ + label: 'A', + value: 'a' + }] + }, {}, counters); + const b = buildV4StringSelect({ + type: 3, + scnx_action: {type: 'roleElement'}, + options: [{ + label: 'B', + value: 'b' + }] + }, {}, counters); + expect(a.data.custom_id).toBe('select-roles-0'); + expect(b.data.custom_id).toBe('select-roles-1'); + }); + + test('scnx_action customCommandElement uses cc counter', () => { + const counters = { + roleSelect: 0, + ccSelect: 0 + }; + const a = buildV4StringSelect({ + type: 3, + scnx_action: {type: 'customCommandElement'}, + options: [{ + label: 'A', + value: 'a' + }] + }, {}, counters); + expect(a.data.custom_id).toBe('cc-select-0'); + }); + + test('option emoji is forwarded when not "null"', () => { + const sel = buildV4StringSelect({ + type: 3, + custom_id: 's', + options: [{ + label: 'A', + value: 'a', + emoji: '🔥' + }] + }, {}, {}); + expect(sel.options[0].data.emoji).toMatchObject({name: '🔥'}); + }); + + test('option emoji "null" is skipped', () => { + const sel = buildV4StringSelect({ + type: 3, + custom_id: 's', + options: [{ + label: 'A', + value: 'a', + emoji: 'null' + }] + }, {}, {}); + expect(sel.options[0].data.emoji).toBeUndefined(); + }); +}); + +describe('embedType v4 - Section (type 9)', () => { + test('returns null when accessory missing', () => { + const out = buildV4Component({ + type: 9, + components: [{ + type: 10, + content: 'hello' + }] + }, {}); + expect(out).toBeNull(); + }); + + test('returns null when no text components', () => { + const out = buildV4Component({ + type: 9, + components: [{ + type: 10, + content: '' + }], + accessory: { + type: 11, + media: {url: 'https://x/t.png'} + } + }, {}); + expect(out).toBeNull(); + }); + + test('returns null when no components array', () => { + expect(buildV4Component({ + type: 9, + accessory: { + type: 11, + media: {url: 'https://x/t.png'} + } + }, {})).toBeNull(); + }); + + test('caps text displays at 3', () => { + const text = (n) => ({ + type: 10, + content: `t${n}` + }); + const sect = buildV4Component({ + type: 9, + components: [text(1), text(2), text(3), text(4), text(5)], + accessory: { + type: 11, + media: {url: 'https://x/t.png'} + } + }, {}); + expect(sect.components).toHaveLength(3); + }); + + test('thumbnail accessory with description and spoiler', () => { + const sect = buildV4Component({ + type: 9, + components: [{ + type: 10, + content: 'side' + }], + accessory: { + type: 11, + media: {url: 'https://x/t.png'}, + description: 'thumb', + spoiler: true + } + }, {}); + expect(sect.accessory).toBeTruthy(); + }); + + test('thumbnail accessory returns null when media missing', () => { + const sect = buildV4Component({ + type: 9, + components: [{ + type: 10, + content: 'side' + }], + accessory: {type: 11} + }, {}); + expect(sect).toBeNull(); + }); + + test('button accessory works', () => { + const sect = buildV4Component({ + type: 9, + components: [{ + type: 10, + content: 'side' + }], + accessory: { + type: 2, + style: 1, + label: 'Go', + custom_id: 'g' + } + }, {}); + expect(sect).toBeTruthy(); + }); + + test('button accessory returns null when button invalid', () => { + const sect = buildV4Component({ + type: 9, + components: [{ + type: 10, + content: 'side' + }], + accessory: { + type: 2, + style: 1 + } + }, {}); + expect(sect).toBeNull(); + }); + + test('unknown accessory type returns null', () => { + const sect = buildV4Component({ + type: 9, + components: [{ + type: 10, + content: 'side' + }], + accessory: {type: 99} + }, {}); + expect(sect).toBeNull(); + }); +}); + +describe('embedType v4 - Container (type 17)', () => { + test('returns null when components array missing or empty', () => { + expect(buildV4Component({type: 17}, {})).toBeNull(); + expect(buildV4Component({ + type: 17, + components: [] + }, {})).toBeNull(); + }); + + test('returns null when no children build successfully', () => { + const out = buildV4Component({ + type: 17, + components: [{ + type: 10, + content: '' + }, {type: 99}] + }, {}); + expect(out).toBeNull(); + }); + + test('accepts numeric accent_color', () => { + const c = buildV4Component({ + type: 17, + accent_color: 0x123456, + components: [{ + type: 10, + content: 'hi' + }] + }, {}); + expect(c.data.accent_color).toBe(0x123456); + }); + + test('accepts named accent_color via parseColor', () => { + const c = buildV4Component({ + type: 17, + accent_color: 'BLURPLE', + components: [{ + type: 10, + content: 'hi' + }] + }, {}); + expect(c.data.accent_color).toBe(0x5865F2); + }); + + test('spoiler flag flows through', () => { + const c = buildV4Component({ + type: 17, + spoiler: true, + components: [{ + type: 10, + content: 'hi' + }] + }, {}); + expect(c.data.spoiler).toBe(true); + }); + + test('adds TextDisplay children', () => { + const c = buildV4Component({ + type: 17, + components: [{ + type: 10, + content: 'A' + }, { + type: 10, + content: 'B' + }] + }, {}); + expect(c.components.filter((x) => x.constructor.name === 'TextDisplayBuilder')).toHaveLength(2); + }); + + test('adds Separator children', () => { + const c = buildV4Component({ + type: 17, + components: [{ + type: 10, + content: 'hello' + }, {type: 14}] + }, {}); + expect(c).toBeTruthy(); + expect(c.components.length).toBeGreaterThanOrEqual(2); + }); + + test('adds MediaGallery children', () => { + const c = buildV4Component({ + type: 17, + components: [ + { + type: 10, + content: 'hello' + }, + { + type: 12, + items: [{media: {url: 'https://x/i.png'}}] + } + ] + }, {}); + expect(c).toBeTruthy(); + }); + + test('adds ActionRow children with buttons', () => { + const c = buildV4Component({ + type: 17, + components: [ + { + type: 10, + content: 'hello' + }, + { + type: 1, + components: [{ + type: 2, + style: 1, + label: 'X', + custom_id: 'x' + }] + } + ] + }, {}); + expect(c).toBeTruthy(); + }); + + test('adds Section children', () => { + const c = buildV4Component({ + type: 17, + components: [ + { + type: 10, + content: 'hello' + }, + { + type: 9, + components: [{ + type: 10, + content: 'side' + }], + accessory: { + type: 11, + media: {url: 'https://x/t.png'} + } + } + ] + }, {}); + expect(c).toBeTruthy(); + }); + + test('logs and continues when a child build throws', () => { + const c = buildV4Component({ + type: 17, + components: [{ + type: 10, + content: 'good' + }, null, { + type: 10, + content: 'also good' + }] + }, {}); + expect(c).toBeTruthy(); + }); +}); + +describe('embedType v4 - dynamicImage placeholder', () => { + test('dynamicImage emits a MediaGalleryBuilder', () => { + const out = buildV4Component({type: 'dynamicImage'}, {}); + expect(out).toBeTruthy(); + expect(out.items).toHaveLength(1); + expect(out.items[0].data.media.url).toBe('attachment://image.png'); + }); + + test('top-level dynamicImage sets _hasDynamicImagePlaceholder flag', () => { + const out = embedType({ + _schema: 'v4', + components: [{type: 'dynamicImage'}] + }); + expect(out._hasDynamicImagePlaceholder).toBe(true); + }); + + test('nested dynamicImage inside container also sets the flag', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 17, + components: [{ + type: 10, + content: 'x' + }, {type: 'dynamicImage'}] + }] + }); + expect(out._hasDynamicImagePlaceholder).toBe(true); + }); + + test('non-v4 input does not set the flag', () => { + const out = embedType({title: 't'}); + expect(out._hasDynamicImagePlaceholder).toBeUndefined(); + }); +}); + +describe('embedType v4 - unknown component types', () => { + test('unknown numeric type returns null', () => { + expect(buildV4Component({type: 999}, {})).toBeNull(); + }); + + test('unknown string type returns null', () => { + expect(buildV4Component({type: 'foo'}, {})).toBeNull(); + }); +}); + +describe('embedType v4 - args substitution depth', () => { + test('args propagate to nested container child labels', () => { + const out = embedType({ + _schema: 'v4', + components: [{ + type: 17, + components: [ + { + type: 10, + content: 'Welcome %who%' + }, + { + type: 1, + components: [{ + type: 2, + style: 1, + label: 'Hi %who%', + custom_id: 'x' + }] + } + ] + }] + }, {'%who%': 'Alice'}); + expect(out).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/tests/helpers/helpers.channelLocks.test.js b/tests/helpers/helpers.channelLocks.test.js new file mode 100644 index 00000000..3a5b64f5 --- /dev/null +++ b/tests/helpers/helpers.channelLocks.test.js @@ -0,0 +1,195 @@ +/* + * Covers lockChannel / unlockChannel — focusing on the thread branches (which take a + * simpler setLocked path) and the ChannelLock model interactions, plus the + * disableModule scnx reportIssue branch. ./scnx-integration is mocked at top level. + */ +jest.mock('../../src/functions/scnx-integration', () => ({ + reportIssue: jest.fn(async () => { + }) +}), {virtual: true}); + +const scnx = require('../../src/functions/scnx-integration'); +const mainStub = require('../__stubs__/main'); +const { + lockChannel, + unlockChannel, + disableModule +} = require('../../src/functions/helpers'); +const { + ChannelType, + PermissionFlagsBits +} = require('discord.js'); + +beforeEach(() => { + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'f', + footerImgUrl: '', + disableFooterTimestamp: false + }; + mainStub.client.scnxSetup = false; + mainStub.client.modules = {}; + mainStub.client.logger = { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + log: jest.fn() + }; + mainStub.client.logChannel = null; + scnx.reportIssue.mockClear(); +}); + +describe('lockChannel (thread branch)', () => { + test('public thread: destroys any existing lock then calls setLocked(true)', async () => { + const destroy = jest.fn().mockResolvedValue(); + const setLocked = jest.fn().mockResolvedValue(); + const channel = { + id: 'thread-1', + type: ChannelType.PublicThread, + setLocked, + client: { + models: {ChannelLock: {findOne: jest.fn().mockResolvedValue({destroy})}} + } + }; + await lockChannel(channel, [], 'lockdown'); + expect(destroy).toHaveBeenCalled(); + expect(setLocked).toHaveBeenCalledWith(true, 'lockdown'); + }); + + test('private thread without prior lock still locks', async () => { + const setLocked = jest.fn().mockResolvedValue(); + const channel = { + id: 'thread-2', + type: ChannelType.PrivateThread, + setLocked, + client: {models: {ChannelLock: {findOne: jest.fn().mockResolvedValue(null)}}} + }; + await lockChannel(channel); + expect(setLocked).toHaveBeenCalledWith(true, expect.any(String)); + }); +}); + +/* + * Regression for bug #cmpwxd: closing a ticket left it visible to @everyone. + * lockChannel must MERGE into the existing @everyone overwrite (which already + * denies VIEW_CHANNEL) rather than replace it wholesale - otherwise the + * VIEW_CHANNEL deny is wiped and the closed ticket becomes public. + */ +describe('lockChannel (normal channel branch)', () => { + test('updates the @everyone overwrite via edit (merge) so VIEW_CHANNEL deny is preserved', async () => { + const create = jest.fn().mockResolvedValue(); + const edit = jest.fn().mockResolvedValue(); + const everyoneRole = {id: 'guild-1'}; + + /* + * Existing @everyone overwrite already denies SendMessages (and VIEW_CHANNEL), + * matching how the tickets module locks down the channel on creation. + */ + const everyoneOverwrite = { + id: 'guild-1', + type: 'role', + deny: {has: perm => perm === PermissionFlagsBits.SendMessages} + }; + + const channel = { + id: 'ticket-chan', + type: ChannelType.GuildText, + parent: null, + permissionOverwrites: { + cache: new Map([['guild-1', everyoneOverwrite]]), + create, + edit + }, + guild: {roles: {everyone: everyoneRole}}, + client: { + user: {id: 'bot-user'}, + guild: {members: {me: {roles: {botRole: {id: 'bot-role'}}}}}, + models: { + ChannelLock: { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue() + } + }, + logger: { + error: jest.fn(), + warn: jest.fn() + } + } + }; + + await lockChannel(channel, [], 'closing ticket'); + + expect(edit).toHaveBeenCalledWith(everyoneRole, expect.objectContaining({SendMessages: false}), expect.anything()); + // A wholesale create() would drop the pre-existing VIEW_CHANNEL deny. + expect(create).not.toHaveBeenCalledWith(everyoneRole, expect.anything(), expect.anything()); + }); +}); + +describe('unlockChannel', () => { + test('thread branch calls setLocked(false)', async () => { + const setLocked = jest.fn().mockResolvedValue(); + const channel = { + id: 't', + type: ChannelType.PublicThread, + setLocked, + client: {models: {ChannelLock: {findOne: jest.fn().mockResolvedValue(null)}}} + }; + await unlockChannel(channel, 'reopen'); + expect(setLocked).toHaveBeenCalledWith(false, 'reopen'); + }); + + test('restores stored permission overwrites for a normal channel', async () => { + const set = jest.fn().mockResolvedValue(); + const channel = { + id: 'c', + type: ChannelType.GuildText, + permissionOverwrites: {set}, + client: { + models: {ChannelLock: {findOne: jest.fn().mockResolvedValue({permissions: [{id: 'role-1'}]})}}, + logger: {error: jest.fn()} + } + }; + await unlockChannel(channel, 'reopen'); + expect(set).toHaveBeenCalledWith([{id: 'role-1'}], 'reopen'); + }); + + test('logs an error when no stored lock data is found for a normal channel', async () => { + const error = jest.fn(); + const channel = { + id: 'c2', + type: ChannelType.GuildText, + permissionOverwrites: {set: jest.fn()}, + client: { + models: {ChannelLock: {findOne: jest.fn().mockResolvedValue(null)}}, + logger: {error} + } + }; + await unlockChannel(channel); + expect(error).toHaveBeenCalled(); + expect(channel.permissionOverwrites.set).not.toHaveBeenCalled(); + }); +}); + +describe('disableModule (scnx reportIssue branch)', () => { + test('reports the issue to scnx when scnxSetup is enabled', () => { + mainStub.client.scnxSetup = true; + mainStub.client.modules.broken = {enabled: true}; + disableModule('broken', 'config error'); + expect(mainStub.client.modules.broken.enabled).toBe(false); + expect(scnx.reportIssue).toHaveBeenCalledWith(mainStub.client, expect.objectContaining({ + type: 'MODULE_FAILURE', + module: 'broken', + errorData: {reason: 'config error'} + })); + }); + + test('does not call reportIssue when scnxSetup is false', () => { + mainStub.client.scnxSetup = false; + mainStub.client.modules.x = {enabled: true}; + disableModule('x'); + expect(scnx.reportIssue).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/helpers/helpers.formatting.test.js b/tests/helpers/helpers.formatting.test.js new file mode 100644 index 00000000..f1fe90c3 --- /dev/null +++ b/tests/helpers/helpers.formatting.test.js @@ -0,0 +1,172 @@ +/* + * Edge-case coverage for the locale/duration/date formatting helpers: + * formatNumber (locale + Intl options + non-numeric), formatVoiceDuration and + * formatDurationShort boundary transitions, dateToDiscordTimestamp rounding, + * and formatDate skipDiscordFormat zero-padding. Complements dateFormatting and + * pureHelpers which cover the happy paths. + */ +const mainStub = require('../__stubs__/main'); +const helpers = require('../../src/functions/helpers'); +const { + formatNumber, + formatVoiceDuration, + formatDurationShort, + dateToDiscordTimestamp, + formatDate +} = helpers; + +beforeEach(() => { + mainStub.client.bcp47Locale = 'en-US'; + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'f', + footerImgUrl: '', + disableFooterTimestamp: false + }; +}); + +describe('formatNumber (edge cases)', () => { + test('formats with German locale grouping/decimal separators', () => { + mainStub.client.bcp47Locale = 'de-DE'; + expect(formatNumber(1234567.89)).toBe('1.234.567,89'); + }); + + test('formats zero', () => { + expect(formatNumber(0)).toBe('0'); + }); + + test('formats negative numbers', () => { + expect(formatNumber(-2500)).toBe('-2,500'); + }); + + test('non-numeric string parses to NaN and Intl renders "NaN"', () => { + expect(formatNumber('not-a-number')).toBe('NaN'); + }); + + test('currency style option is honored', () => { + expect(formatNumber(5, { + style: 'currency', + currency: 'USD' + })).toBe('$5.00'); + }); + + test('maximumFractionDigits option rounds the value', () => { + expect(formatNumber(3.14159, {maximumFractionDigits: 2})).toBe('3.14'); + }); + + test('parses an integer-looking string', () => { + expect(formatNumber('1000000')).toBe('1,000,000'); + }); +}); + +describe('formatVoiceDuration (boundaries)', () => { + test('NaN/negative/zero all map to 0 minutes', () => { + expect(formatVoiceDuration(NaN)).toBe('helpers.voice-time-m(i=0)'); + expect(formatVoiceDuration(-1)).toBe('helpers.voice-time-m(i=0)'); + expect(formatVoiceDuration(0)).toBe('helpers.voice-time-m(i=0)'); + }); + + test('1 second renders the seconds key', () => { + expect(formatVoiceDuration(1)).toBe('helpers.voice-time-s(i=1)'); + }); + + test('59 seconds is still the seconds key', () => { + expect(formatVoiceDuration(59)).toBe('helpers.voice-time-s(i=59)'); + }); + + test('exactly 60 crosses to the minutes key', () => { + expect(formatVoiceDuration(60)).toBe('helpers.voice-time-m(i=1)'); + }); + + test('3599 seconds is still minutes (59m)', () => { + expect(formatVoiceDuration(3599)).toBe('helpers.voice-time-m(i=59)'); + }); + + test('exactly 3600 crosses to hours+minutes (1h 0m)', () => { + expect(formatVoiceDuration(3600)).toBe('helpers.voice-time-hm(h=1,m=0)'); + }); + + test('fractional seconds are floored', () => { + expect(formatVoiceDuration(90.9)).toBe('helpers.voice-time-m(i=1)'); + }); + + test('large multi-hour duration', () => { + // 7325s = 2h 2m 5s -> 2h 2m + expect(formatVoiceDuration(7325)).toBe('helpers.voice-time-hm(h=2,m=2)'); + }); +}); + +describe('formatDurationShort (boundaries)', () => { + test('Infinity and -Infinity fall to just-now (not finite)', () => { + expect(formatDurationShort(Infinity)).toBe('helpers.duration-just-now'); + expect(formatDurationShort(-Infinity)).toBe('helpers.duration-just-now'); + }); + + test('exactly 60_000 is one minute (boundary of just-now)', () => { + expect(formatDurationShort(60_000)).toBe('helpers.duration-minute(i=1)'); + }); + + test('one year exactly uses the singular year key', () => { + const year = 365 * 24 * 60 * 60 * 1000; + expect(formatDurationShort(year)).toBe('helpers.duration-year(i=1)'); + }); + + test('two years uses plural', () => { + const year = 365 * 24 * 60 * 60 * 1000; + expect(formatDurationShort(2 * year)).toBe('helpers.duration-years(i=2)'); + }); + + test('one month boundary picks month, not 30 days', () => { + const month = 30 * 24 * 60 * 60 * 1000; + expect(formatDurationShort(month)).toBe('helpers.duration-month(i=1)'); + }); + + test('23 hours stays in hours', () => { + expect(formatDurationShort(23 * 60 * 60 * 1000)).toBe('helpers.duration-hours(i=23)'); + }); + + test('29 days stays in days (just under a month)', () => { + expect(formatDurationShort(29 * 24 * 60 * 60 * 1000)).toBe('helpers.duration-days(i=29)'); + }); +}); + +describe('dateToDiscordTimestamp (rounding)', () => { + test('rounds 1999ms up to 2 seconds (toFixed(0) is round-half)', () => { + expect(dateToDiscordTimestamp(new Date(1999))).toBe(''); + }); + + test('500ms rounds to 1 (round-half-to-even / nearest)', () => { + expect(dateToDiscordTimestamp(new Date(500))).toBe(''); + }); + + test('all documented style suffixes pass through', () => { + const d = new Date(1700000000_000); + for (const style of ['t', 'T', 'd', 'D', 'f', 'F', 'R']) { + expect(dateToDiscordTimestamp(d, style)).toBe(``); + } + }); +}); + +describe('formatDate skipDiscordFormat (zero padding)', () => { + test('single-digit day/month/hour/minute are zero-padded', () => { + const d = new Date(2024, 2, 7, 8, 5); // March 7 08:05 (local) + const out = formatDate(d, true); + expect(out).toMatch(/dd=07/); + expect(out).toMatch(/mm=03/); + expect(out).toMatch(/hh=08/); + expect(out).toMatch(/min=05/); + expect(out).toMatch(/yyyy=2024/); + }); + + test('double-digit values are not padded', () => { + const d = new Date(2024, 10, 25, 14, 30); // Nov 25 14:30 + const out = formatDate(d, true); + expect(out).toMatch(/dd=25/); + expect(out).toMatch(/mm=11/); + expect(out).toMatch(/hh=14/); + expect(out).toMatch(/min=30/); + }); +}); \ No newline at end of file diff --git a/tests/helpers/helpers.miscBranches.test.js b/tests/helpers/helpers.miscBranches.test.js new file mode 100644 index 00000000..84d4650b --- /dev/null +++ b/tests/helpers/helpers.miscBranches.test.js @@ -0,0 +1,198 @@ +/* + * Remaining branch coverage: safeSetFooter iconURL override, getGlobalArgs avatar/guild-icon + * fallbacks, formatDiscordUserName tag-vs-fallback nuances, embedTypeV2 non-scnx passthrough, + * and direct invocation of the __test.embedTypeSchemaV2 / embedTypeSchemaV4 internals. + */ +const mainStub = require('../__stubs__/main'); +const helpers = require('../../src/functions/helpers'); +const {__test} = helpers; +const { + MessageEmbed, + MessageFlags +} = require('discord.js'); + +beforeEach(() => { + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'test-footer', + footerImgUrl: '', + disableFooterTimestamp: false + }; + mainStub.client.scnxSetup = false; + mainStub.client.user = null; + mainStub.client.guild = null; + mainStub.client.bcp47Locale = 'en-US'; +}); + +describe('safeSetFooter (more branches)', () => { + test('customIconURL overrides client.strings.footerImgUrl', () => { + const client = { + strings: { + footer: 'F', + footerImgUrl: 'https://default/i.png' + } + }; + const embed = new MessageEmbed(); + helpers.safeSetFooter(embed, client, null, 'https://custom/i.png'); + expect(embed.data.footer.icon_url).toBe('https://custom/i.png'); + }); + + test('custom text with custom icon both apply', () => { + const embed = new MessageEmbed(); + helpers.safeSetFooter(embed, {strings: {}}, 'hi', 'https://x/i.png'); + expect(embed.data.footer).toEqual({ + text: 'hi', + icon_url: 'https://x/i.png' + }); + }); + + test('footer with text but no icon stores null icon_url', () => { + const embed = new MessageEmbed(); + helpers.safeSetFooter(embed, {strings: {footer: 'only text'}}); + expect(embed.data.footer.text).toBe('only text'); + expect(embed.data.footer.icon_url).toBeNull(); + }); + + test('whitespace-only custom text wins precedence but is rejected by trim check (no footer set)', () => { + // customText ' ' is truthy so it is selected over the client footer, then the + // trim().length>0 guard rejects it, leaving no footer at all. + const client = {strings: {footer: 'fallback'}}; + const embed = new MessageEmbed(); + helpers.safeSetFooter(embed, client, ' '); + expect(embed.data.footer).toBeUndefined(); + }); +}); + +describe('getGlobalArgs (avatar/guild fallbacks)', () => { + test('empty displayAvatarURL yields empty %botAvatar%', () => { + mainStub.client.user = { + id: 'b', + tag: 'b#0', + username: 'u', + displayName: 'u', + displayAvatarURL: () => '', + toString: () => '<@b>' + }; + expect(__test.getGlobalArgs()['%botAvatar%']).toBe(''); + }); + + test('guild with empty iconURL yields empty %guildIcon%', () => { + mainStub.client.user = { + id: 'b', + tag: 'b#0', + username: 'u', + displayName: 'u', + displayAvatarURL: () => 'a', + toString: () => '<@b>' + }; + mainStub.client.guild = { + id: 'g', + name: 'G', + iconURL: () => '' + }; + expect(__test.getGlobalArgs()['%guildIcon%']).toBe(''); + }); + + test('returns empty object when client.user is undefined', () => { + mainStub.client.user = undefined; + expect(__test.getGlobalArgs()).toEqual({}); + }); +}); + +describe('formatDiscordUserName (more nuances)', () => { + test('legacy user with tag prefers tag over reconstruction', () => { + expect(helpers.formatDiscordUserName({ + discriminator: '9999', + username: 'name', + tag: 'Pretty#9999' + })).toBe('Pretty#9999'); + }); + + test('new-style user without addAtToUsernames setting omits @', () => { + mainStub.client.strings = {}; + expect(helpers.formatDiscordUserName({ + discriminator: '0', + username: 'plain' + })).toBe('plain'); + }); + + test('new-style user with addAtToUsernames false omits @', () => { + mainStub.client.strings = {addAtToUsernames: false}; + expect(helpers.formatDiscordUserName({ + discriminator: '0', + username: 'plain' + })).toBe('plain'); + }); +}); + +describe('embedTypeV2 non-scnx passthrough', () => { + test('without scnxSetup returns embedType result directly', async () => { + mainStub.client.scnxSetup = false; + const out = await helpers.embedTypeV2({ + _schema: 'v2', + title: 'Plain' + }, {}, {}); + expect(out.embeds[0].data.title).toBe('Plain'); + }); + + test('string input passes through the wrapper', async () => { + const out = await helpers.embedTypeV2('hi %who%', {'%who%': 'there'}, {}); + expect(out.content).toBe('hi there'); + }); +}); + +describe('__test.embedTypeSchemaV2 (direct)', () => { + test('builds an embed from a title-only input', () => { + const out = __test.embedTypeSchemaV2({title: 'Direct'}, {}, {}); + expect(out.embeds[0].data.title).toBe('Direct'); + }); + + test('content comes from the "message" field', () => { + const out = __test.embedTypeSchemaV2({ + title: 'T', + message: 'body' + }, {}, {}); + expect(out.content).toBe('body'); + }); + + test('no message field yields null content', () => { + const out = __test.embedTypeSchemaV2({title: 'T'}, {}, {}); + expect(out.content).toBeNull(); + }); + + test('embedTimestamp overrides the auto timestamp', () => { + const ts = new Date(1700000000_000); + const out = __test.embedTypeSchemaV2({ + title: 'T', + embedTimestamp: ts + }, {}, {}); + expect(new Date(out.embeds[0].data.timestamp).getTime()).toBe(ts.getTime()); + }); +}); + +describe('__test.embedTypeSchemaV4 (direct)', () => { + test('sets the IsComponentsV2 flag', () => { + const out = __test.embedTypeSchemaV4({components: []}, {}, {}); + expect(out.flags & MessageFlags.IsComponentsV2).toBe(MessageFlags.IsComponentsV2); + }); + + test('always nulls content and empties embeds', () => { + const out = __test.embedTypeSchemaV4({ + components: [{ + type: 10, + content: 'x' + }] + }, {}, {}); + expect(out.content).toBeNull(); + expect(out.embeds).toEqual([]); + }); + + test('preserves a pre-existing numeric flag via OR', () => { + const out = __test.embedTypeSchemaV4({components: []}, {}, {flags: 4}); + expect(out.flags & 4).toBe(4); + expect(out.flags & MessageFlags.IsComponentsV2).toBe(MessageFlags.IsComponentsV2); + }); +}); \ No newline at end of file diff --git a/tests/helpers/helpers.pasteInternals.test.js b/tests/helpers/helpers.pasteInternals.test.js new file mode 100644 index 00000000..b3d33340 --- /dev/null +++ b/tests/helpers/helpers.pasteInternals.test.js @@ -0,0 +1,301 @@ +/* + * Unit tests for the PrivateBin paste building blocks exposed via helpers.__test: + * base58Encode, encryptPrivatebinPaste, classifyHttpStatus, parseRetryAfterMs, + * computePasteRetryDelayMs and classifyPrivatebinResponse. These are pure-ish + * (some use crypto/Math.random) and are not otherwise covered by existing suites. + */ +const helpers = require('../../src/functions/helpers'); +const {__test} = helpers; +const { + base58Encode, + encryptPrivatebinPaste, + classifyHttpStatus, + parseRetryAfterMs, + computePasteRetryDelayMs, + classifyPrivatebinResponse +} = __test; + +afterEach(() => jest.restoreAllMocks()); + +describe('base58Encode', () => { + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + + test('empty buffer returns empty string', () => { + expect(base58Encode(Buffer.from([]))).toBe(''); + }); + + test('single zero byte encodes to "1"', () => { + expect(base58Encode(Buffer.from([0]))).toBe('1'); + }); + + test('leading zero bytes become leading "1"s', () => { + expect(base58Encode(Buffer.from([0, 0, 0]))).toBe('111'); + }); + + test('value 57 maps to the last alphabet char', () => { + expect(base58Encode(Buffer.from([57]))).toBe(ALPHABET[57]); + }); + + test('value 58 rolls over to "21"', () => { + // 58 = 1*58 + 0 -> digits [1,0] -> alphabet[1] + alphabet[0] = '2' + '1' + expect(base58Encode(Buffer.from([58]))).toBe('21'); + }); + + test('known multi-byte vector (the string "Hello World!")', () => { + // Canonical base58 of ASCII "Hello World!" + expect(base58Encode(Buffer.from('Hello World!', 'utf8'))).toBe('2NEpo7TZRRrLZSi2U'); + }); + + test('leading zeros are preserved alongside encoded payload', () => { + const out = base58Encode(Buffer.from([0, 0, 1])); + expect(out.startsWith('11')).toBe(true); + expect(out).toBe('112'); + }); + + test('output only contains base58 alphabet characters', () => { + const out = base58Encode(Buffer.from([255, 254, 253, 1, 2, 3, 99])); + for (const ch of out) expect(ALPHABET.includes(ch)).toBe(true); + }); + + test('is deterministic for the same input', () => { + const buf = Buffer.from([12, 34, 56, 78, 90]); + expect(base58Encode(buf)).toBe(base58Encode(buf)); + }); +}); + +describe('encryptPrivatebinPaste', () => { + const crypto = require('crypto'); + + test('returns base64 ciphertext and a well-formed adata tuple', () => { + const key = crypto.randomBytes(32); + const { + ct, + adata + } = encryptPrivatebinPaste('secret', key, {}); + expect(typeof ct).toBe('string'); + // base64 of (ciphertext + 16-byte GCM tag) is non-empty + expect(ct.length).toBeGreaterThan(0); + expect(Buffer.from(ct, 'base64').length).toBeGreaterThanOrEqual(16); + // adata: [[iv, salt, iterations, 256, tagbits, 'aes', 'gcm', compression], format, opendiscussion, burn] + expect(Array.isArray(adata)).toBe(true); + expect(adata).toHaveLength(4); + const spec = adata[0]; + expect(spec[2]).toBe(100000); // PBKDF2 iterations + expect(spec[3]).toBe(256); + expect(spec[4]).toBe(128); // GCM tag bits + expect(spec[5]).toBe('aes'); + expect(spec[6]).toBe('gcm'); + expect(spec[7]).toBe('zlib'); // default compression + }); + + test('defaults textformat to plaintext and flags to 0', () => { + const {adata} = encryptPrivatebinPaste('x', crypto.randomBytes(32), {}); + expect(adata[1]).toBe('plaintext'); + expect(adata[2]).toBe(0); // opendiscussion + expect(adata[3]).toBe(0); // burnafterreading + }); + + test('honors opendiscussion and burnafterreading truthy options as 1', () => { + const {adata} = encryptPrivatebinPaste('x', crypto.randomBytes(32), { + opendiscussion: true, + burnafterreading: true, + textformat: 'markdown' + }); + expect(adata[1]).toBe('markdown'); + expect(adata[2]).toBe(1); + expect(adata[3]).toBe(1); + }); + + test('honors a custom compression value in the spec', () => { + const {adata} = encryptPrivatebinPaste('x', crypto.randomBytes(32), {compression: 'none'}); + expect(adata[0][7]).toBe('none'); + }); + + test('iv and salt are valid base64 of expected byte lengths', () => { + const {adata} = encryptPrivatebinPaste('x', crypto.randomBytes(32), {}); + expect(Buffer.from(adata[0][0], 'base64')).toHaveLength(16); // iv + expect(Buffer.from(adata[0][1], 'base64')).toHaveLength(8); // salt + }); + + test('produces different ciphertext across calls (random iv/salt)', () => { + const key = crypto.randomBytes(32); + const a = encryptPrivatebinPaste('same', key, {}); + const b = encryptPrivatebinPaste('same', key, {}); + expect(a.ct).not.toBe(b.ct); + }); + + test('round-trips: zlib-decompressed plaintext decrypts back to the paste', () => { + const zlib = require('zlib'); + const masterKey = crypto.randomBytes(32); + // Reconstruct decryption using the emitted adata. + const { + ct, + adata + } = encryptPrivatebinPaste('round-trip-me', masterKey, {}); + const spec = adata[0]; + const iv = Buffer.from(spec[0], 'base64'); + const salt = Buffer.from(spec[1], 'base64'); + const derivedKey = crypto.pbkdf2Sync(masterKey, salt, spec[2], 32, 'sha256'); + const raw = Buffer.from(ct, 'base64'); + const tag = raw.subarray(raw.length - 16); + const body = raw.subarray(0, raw.length - 16); + const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv, {authTagLength: 16}); + decipher.setAAD(Buffer.from(JSON.stringify(adata), 'utf8')); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(body), decipher.final()]); + const json = JSON.parse(zlib.inflateRawSync(decrypted).toString('utf8')); + expect(json.paste).toBe('round-trip-me'); + }); +}); + +describe('parseRetryAfterMs', () => { + test('returns null for missing headers', () => { + expect(parseRetryAfterMs(null)).toBeNull(); + expect(parseRetryAfterMs({})).toBeNull(); + expect(parseRetryAfterMs(undefined)).toBeNull(); + }); + + test('parses lowercase retry-after into milliseconds', () => { + expect(parseRetryAfterMs({'retry-after': '5'})).toBe(5000); + }); + + test('parses capitalized Retry-After header', () => { + expect(parseRetryAfterMs({'Retry-After': '3'})).toBe(3000); + }); + + test('returns null for non-positive or non-numeric values', () => { + expect(parseRetryAfterMs({'retry-after': '0'})).toBeNull(); + expect(parseRetryAfterMs({'retry-after': '-2'})).toBeNull(); + expect(parseRetryAfterMs({'retry-after': 'soon'})).toBeNull(); + }); + + test('caps very large values at PASTE_RETRY_MAX_DELAY_MS (60000)', () => { + expect(parseRetryAfterMs({'retry-after': '9999'})).toBe(60000); + }); + + test('parses numeric prefix via parseInt (e.g. "10s" -> 10000)', () => { + expect(parseRetryAfterMs({'retry-after': '10s'})).toBe(10000); + }); +}); + +describe('classifyHttpStatus', () => { + test('no status (network error) is retryable', () => { + expect(classifyHttpStatus(null, {})).toEqual({ + retryable: true, + retryAfterMs: null + }); + }); + + test('429 is retryable', () => { + expect(classifyHttpStatus(429, {}).retryable).toBe(true); + }); + + test('408 and 425 are retryable', () => { + expect(classifyHttpStatus(408, {}).retryable).toBe(true); + expect(classifyHttpStatus(425, {}).retryable).toBe(true); + }); + + test('5xx range is retryable, 600+ is not', () => { + expect(classifyHttpStatus(500, {}).retryable).toBe(true); + expect(classifyHttpStatus(599, {}).retryable).toBe(true); + expect(classifyHttpStatus(600, {}).retryable).toBe(false); + }); + + test('4xx (non 408/425/429) is not retryable', () => { + expect(classifyHttpStatus(400, {}).retryable).toBe(false); + expect(classifyHttpStatus(403, {}).retryable).toBe(false); + expect(classifyHttpStatus(413, {}).retryable).toBe(false); + }); + + test('2xx/3xx are not retryable', () => { + expect(classifyHttpStatus(200, {}).retryable).toBe(false); + expect(classifyHttpStatus(301, {}).retryable).toBe(false); + }); + + test('passes through parsed retryAfterMs from headers', () => { + expect(classifyHttpStatus(429, {'retry-after': '7'}).retryAfterMs).toBe(7000); + }); + + test('includes the status field when status provided', () => { + expect(classifyHttpStatus(503, {}).status).toBe(503); + }); +}); + +describe('classifyPrivatebinResponse', () => { + test('ok when a non-empty url is present', () => { + expect(classifyPrivatebinResponse({url: '/?abc'})).toEqual({ok: true}); + }); + + test('not ok with default message when url missing', () => { + const r = classifyPrivatebinResponse({}); + expect(r.ok).toBe(false); + expect(r.message).toBe('PrivateBin response missing url'); + }); + + test('empty-string url is treated as missing', () => { + const r = classifyPrivatebinResponse({url: ''}); + expect(r.ok).toBe(false); + }); + + test('prefers message over error field', () => { + const r = classifyPrivatebinResponse({ + message: 'boom', + error: 'other' + }); + expect(r.message).toBe('boom'); + }); + + test('size/large/invalid messages are non-retryable permanent failures', () => { + expect(classifyPrivatebinResponse({message: 'Paste size exceeded'}).retryable).toBe(false); + expect(classifyPrivatebinResponse({message: 'Document too large'}).retryable).toBe(false); + expect(classifyPrivatebinResponse({message: 'Invalid data'}).retryable).toBe(false); + }); + + test('flood/wait/try again/busy messages are retryable', () => { + expect(classifyPrivatebinResponse({message: 'Flood protection'}).retryable).toBe(true); + expect(classifyPrivatebinResponse({message: 'Please wait'}).retryable).toBe(true); + expect(classifyPrivatebinResponse({message: 'try again later'}).retryable).toBe(true); + expect(classifyPrivatebinResponse({message: 'server busy'}).retryable).toBe(true); + }); + + test('unknown error messages default to non-retryable', () => { + expect(classifyPrivatebinResponse({error: 'mystery'}).retryable).toBe(false); + }); + + test('handles null response', () => { + const r = classifyPrivatebinResponse(null); + expect(r.ok).toBe(false); + expect(r.message).toBe('PrivateBin response missing url'); + }); +}); + +describe('computePasteRetryDelayMs', () => { + test('returns the provided retryAfterMs verbatim when set', () => { + expect(computePasteRetryDelayMs(0, 4321)).toBe(4321); + expect(computePasteRetryDelayMs(5, 100)).toBe(100); + }); + + test('exponential backoff with zero jitter at attempt 0', () => { + jest.spyOn(Math, 'random').mockReturnValue(0); + // base = 1000 * 2^0 = 1000, jitter 0 + expect(computePasteRetryDelayMs(0, null)).toBe(1000); + }); + + test('backoff doubles with attempt index', () => { + jest.spyOn(Math, 'random').mockReturnValue(0); + expect(computePasteRetryDelayMs(1, null)).toBe(2000); + expect(computePasteRetryDelayMs(2, null)).toBe(4000); + expect(computePasteRetryDelayMs(3, null)).toBe(8000); + }); + + test('jitter is added on top of the base (0..499)', () => { + jest.spyOn(Math, 'random').mockReturnValue(0.998); // floor(0.998*500)=499 + expect(computePasteRetryDelayMs(0, null)).toBe(1499); + }); + + test('clamps to the 60000ms ceiling for large attempts', () => { + jest.spyOn(Math, 'random').mockReturnValue(0.5); + // attempt 10 -> base 1000*1024 = 1024000, clamped to 60000 + expect(computePasteRetryDelayMs(10, null)).toBe(60000); + }); +}); \ No newline at end of file diff --git a/tests/helpers/helpers.pasteRetry.test.js b/tests/helpers/helpers.pasteRetry.test.js new file mode 100644 index 00000000..d0ae6fc6 --- /dev/null +++ b/tests/helpers/helpers.pasteRetry.test.js @@ -0,0 +1,199 @@ +/* + * Behavioral tests for postToSCNetworkPaste retry/backoff logic. centra is mocked + * per-test via jest.doMock under isolateModules so the network never runs; pasteSleep's + * setTimeout is driven by fake timers so retries resolve instantly. Covers: retry then + * success, exhausting all attempts, transient-then-permanent classification, and the + * non-JSON body short-circuit. + */ + +afterEach(() => { + jest.useRealTimers(); + jest.resetModules(); +}); + +/** Builds a fake centra whose .send() returns queued responses (last one repeats). */ +function mockCentraSequence(responses) { + let i = 0; + jest.doMock('centra', () => () => ({ + header() { + return this; + }, + body() { + return this; + }, + send: async () => { + const r = responses[Math.min(i, responses.length - 1)]; + i++; + if (r instanceof Error) throw r; + return r; + } + })); +} + +function okResponse(url = '/?ok') { + return { + statusCode: 200, + headers: {}, + json: async () => ({ + status: 0, + url + }) + }; +} + +function floodResponse() { + return { + statusCode: 200, + headers: {}, + json: async () => ({ + status: 1, + message: 'Flood protection, please wait' + }) + }; +} + +async function runAllTimers() { + // Flush pending promise microtasks then advance fake timers, repeatedly. + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + jest.runOnlyPendingTimers(); + } +} + +describe('postToSCNetworkPaste retry behavior', () => { + test('retries a flood rejection then succeeds on the next attempt', async () => { + await jest.isolateModulesAsync(async () => { + jest.useFakeTimers(); + mockCentraSequence([floodResponse(), okResponse('/?second')]); + const {postToSCNetworkPaste} = require('../../src/functions/helpers'); + const promise = postToSCNetworkPaste('content'); + await runAllTimers(); + const url = await promise; + expect(url).toMatch(/\/\?second#/); + }); + }); + + test('throws after exhausting all attempts on persistent flood', async () => { + await jest.isolateModulesAsync(async () => { + jest.useFakeTimers(); + mockCentraSequence([floodResponse()]); + const { + postToSCNetworkPaste, + PasteUploadError + } = require('../../src/functions/helpers'); + const promise = postToSCNetworkPaste('content'); + const assertion = expect(promise).rejects.toBeInstanceOf(PasteUploadError); + await runAllTimers(); + await assertion; + }); + }); + + test('network error is retryable and eventually succeeds', async () => { + await jest.isolateModulesAsync(async () => { + jest.useFakeTimers(); + mockCentraSequence([new Error('ECONNRESET'), okResponse('/?recovered')]); + const {postToSCNetworkPaste} = require('../../src/functions/helpers'); + const promise = postToSCNetworkPaste('content'); + await runAllTimers(); + const url = await promise; + expect(url).toMatch(/\/\?recovered#/); + }); + }); + + test('persistent network error throws PasteUploadError with cause', async () => { + await jest.isolateModulesAsync(async () => { + jest.useFakeTimers(); + mockCentraSequence([new Error('DNS fail')]); + const { + postToSCNetworkPaste, + PasteUploadError + } = require('../../src/functions/helpers'); + const promise = postToSCNetworkPaste('content').catch((e) => e); + await runAllTimers(); + const err = await promise; + expect(err).toBeInstanceOf(PasteUploadError); + expect(err.message).toMatch(/network error/i); + expect(err.cause).toBeInstanceOf(Error); + }); + }); + + test('non-JSON response body throws immediately (no retry)', async () => { + await jest.isolateModulesAsync(async () => { + mockCentraSequence([{ + statusCode: 200, + headers: {}, + json: async () => { + throw new Error('Unexpected token <'); + } + }]); + const { + postToSCNetworkPaste, + PasteUploadError + } = require('../../src/functions/helpers'); + await expect(postToSCNetworkPaste('x')).rejects.toBeInstanceOf(PasteUploadError); + }); + }); + + test('permanent size rejection is not retried', async () => { + await jest.isolateModulesAsync(async () => { + mockCentraSequence([ + { + statusCode: 200, + headers: {}, + json: async () => ({ + status: 1, + message: 'Paste size too large' + }) + } + ]); + const { + postToSCNetworkPaste, + PasteUploadError + } = require('../../src/functions/helpers'); + await expect(postToSCNetworkPaste('x')).rejects.toBeInstanceOf(PasteUploadError); + }); + }); + + test('retryable 503 then 200 succeeds', async () => { + await jest.isolateModulesAsync(async () => { + jest.useFakeTimers(); + mockCentraSequence([ + { + statusCode: 503, + headers: {}, + json: async () => ({}) + }, + okResponse('/?after503') + ]); + const {postToSCNetworkPaste} = require('../../src/functions/helpers'); + const promise = postToSCNetworkPaste('content'); + await runAllTimers(); + const url = await promise; + expect(url).toMatch(/\/\?after503#/); + }); + }); +}); + +describe('PasteUploadError shape', () => { + test('carries name, retryable and retryAfterMs metadata', () => { + const {PasteUploadError} = require('../../src/functions/helpers'); + const err = new PasteUploadError('boom', { + retryable: true, + retryAfterMs: 1234 + }); + expect(err.name).toBe('PasteUploadError'); + expect(err.message).toBe('boom'); + expect(err.retryable).toBe(true); + expect(err.retryAfterMs).toBe(1234); + expect(err instanceof Error).toBe(true); + }); + + test('defaults to non-retryable with null metadata', () => { + const {PasteUploadError} = require('../../src/functions/helpers'); + const err = new PasteUploadError('x'); + expect(err.retryable).toBe(false); + expect(err.response).toBeNull(); + expect(err.cause).toBeNull(); + expect(err.retryAfterMs).toBeNull(); + }); +}); \ No newline at end of file diff --git a/tests/helpers/helpers.pureEdgeCases.test.js b/tests/helpers/helpers.pureEdgeCases.test.js new file mode 100644 index 00000000..f1539236 --- /dev/null +++ b/tests/helpers/helpers.pureEdgeCases.test.js @@ -0,0 +1,211 @@ +/* + * Deeper edge-case coverage for the pure string/number/array helpers, complementing + * pureHelpers/pureMisc which assert the happy paths. Focuses on boundaries (exact-length + * truncation, odd/even puffer parity, 5*i progressbar thresholds), unusual inputs and + * the internal branches of compareArrays/parseEmbedColor/inputReplacer. + */ +const helpers = require('../../src/functions/helpers'); +const { + truncate, + pufferStringToSize, + compareArrays, + renderProgressbar, + parseEmbedColor, + inputReplacer +} = helpers; + +describe('truncate (edge cases)', () => { + test('string exactly at the limit is returned unchanged', () => { + expect(truncate('abcdefghij', 10)).toBe('abcdefghij'); + }); + + test('string one over the limit is cut to length-3 plus ellipsis', () => { + // length 11 > 10 -> substr(0, 7) "abcdefg" -> "abcdefg..." + expect(truncate('abcdefghijk', 10)).toBe('abcdefg...'); + }); + + test('trailing whitespace before the cut point is trimmed', () => { + // "ab cd" len 8 > 5 -> substr(0,2)="ab" trim -> "ab..." + expect(truncate('ab cd', 5)).toBe('ab...'); + }); + + test('result length is length (cut text + 3 dots) for long input', () => { + const out = truncate('x'.repeat(100), 20); + expect(out).toHaveLength(20); + expect(out.endsWith('...')).toBe(true); + }); + + test('zero is returned as-is (falsy guard)', () => { + expect(truncate(0, 5)).toBe(0); + }); + + test('whitespace-only over-length collapses to just ellipsis after trim', () => { + // 10 spaces, length 4 -> substr(0,1)=" " trim "" -> "..." + expect(truncate(' ', 4)).toBe('...'); + }); +}); + +describe('pufferStringToSize (edge cases)', () => { + test('string longer than target size is returned unchanged (no negative loop)', () => { + expect(pufferStringToSize('hello', 2)).toBe('hello'); + }); + + test('adds exactly one leading nbsp when one char short (i=0 even -> prepend)', () => { + const out = pufferStringToSize('ab', 3); + expect(out).toBe('\xa0ab'); + expect(out).toHaveLength(3); + }); + + test('two short pads one leading and one trailing', () => { + // i=0 even prepend, i=1 odd append + expect(pufferStringToSize('ab', 4)).toBe('\xa0ab\xa0'); + }); + + test('coerces boolean via toString', () => { + const out = pufferStringToSize(true, 6); + expect(out.includes('true')).toBe(true); + expect(out).toHaveLength(6); + }); + + test('exact-size string is untouched', () => { + expect(pufferStringToSize('exact', 5)).toBe('exact'); + }); +}); + +describe('compareArrays (edge cases)', () => { + test('two empty arrays are equal', () => { + expect(compareArrays([], [])).toBe(true); + }); + + test('order-insensitive for primitives but length still matters', () => { + expect(compareArrays([1, 1, 2], [2, 1, 1])).toBe(true); + }); + + test('duplicate-vs-distinct of same length: includes() makes them equal', () => { + // Both length 2; each element of array1 is in array2 -> true (a known quirk). + expect(compareArrays([1, 1], [1, 2])).toBe(true); + }); + + test('object compared against primitive at same index via key set', () => { + // array1[0] is Object -> key path. keys of {} merged with keys of 5 (none) = none -> equal. + expect(compareArrays([{}], [5])).toBe(true); + }); + + test('extra key present in only one object causes inequality', () => { + expect(compareArrays([{ + a: 1, + b: 2 + }], [{a: 1}])).toBe(false); + }); + + test('null vs missing key treated as equal (?? null)', () => { + expect(compareArrays([{a: null}], [{}])).toBe(true); + }); + + test('nested object identity is shallow (keys compared by ===)', () => { + const shared = {x: 1}; + expect(compareArrays([{a: shared}], [{a: shared}])).toBe(true); + expect(compareArrays([{a: {x: 1}}], [{a: {x: 1}}])).toBe(false); + }); + + test('mixed object and primitive arrays compare per index', () => { + expect(compareArrays([{a: 1}, 'b'], [{a: 1}, 'b'])).toBe(true); + }); +}); + +describe('renderProgressbar (edge cases)', () => { + test('exactly 5% fills only the first cell', () => { + // i=1: 5>=5 true; i>=2: 5>=10 false + expect(renderProgressbar(5, 4)).toBe('█░░░'); + }); + + test('threshold is inclusive at multiples of 5', () => { + // 10% with length 4: i=1(>=5) i=2(>=10) fill; i=3,4 empty + expect(renderProgressbar(10, 4)).toBe('██░░'); + }); + + test('over-100 percentage fills the whole bar', () => { + expect(renderProgressbar(250, 6)).toBe('██████'); + }); + + test('negative percentage renders all empty', () => { + expect(renderProgressbar(-10, 5)).toBe('░░░░░'); + }); + + test('length 0 yields empty string', () => { + expect(renderProgressbar(50, 0)).toBe(''); + }); + + test('length 1 fills only when percentage >= 5', () => { + expect(renderProgressbar(4, 1)).toBe('░'); + expect(renderProgressbar(5, 1)).toBe('█'); + }); +}); + +describe('parseEmbedColor (edge cases)', () => { + test('named GOLD and YELLOW share the same value', () => { + expect(parseEmbedColor('GOLD')).toBe(parseEmbedColor('YELLOW')); + }); + + test('WHITE resolves to 0xFFFFFF', () => { + expect(parseEmbedColor('WHITE')).toBe(0xFFFFFF); + }); + + test('hash hex with multiple hashes still parses (replaceAll)', () => { + expect(parseEmbedColor('#ff0000')).toBe(0xff0000); + }); + + test('zero number passes through unchanged', () => { + // 0 is falsy in colors[] lookup but typeof number short-circuits. + expect(parseEmbedColor(0)).toBe(0); + }); + + test('non-hex string yields NaN via parseInt', () => { + expect(Number.isNaN(parseEmbedColor('zzz'))).toBe(true); + }); + + test('lowercase color name is not in the table -> parsed as hex', () => { + // 'red' is not a key; parseInt('red', 16) -> NaN + expect(Number.isNaN(parseEmbedColor('red'))).toBe(true); + }); + + test('boolean returns the value unchanged (no branch matches)', () => { + expect(parseEmbedColor(true)).toBe(true); + }); +}); + +describe('inputReplacer (edge cases)', () => { + test('returnNull=false with empty args returns empty string for null', () => { + expect(inputReplacer({}, null, false)).toBe(''); + }); + + test('numeric arg value is interpolated as string', () => { + expect(inputReplacer({'%n%': 0}, 'count=%n%')).toBe('count=0'); + }); + + test('replaces overlapping placeholder names independently', () => { + expect(inputReplacer({ + '%a%': 'X', + '%ab%': 'Y' + }, '%ab%-%a%')).toContain('Y'); + }); + + test('returnNull=true returns null when all substitutions yield empty string', () => { + // input '%x%' with x='' -> becomes '' -> returns null at the end + expect(inputReplacer({'%x%': ''}, '%x%', true)).toBeNull(); + }); + + test('mutates undefined arg values into empty string in place', () => { + const args = {'%u%': undefined}; + inputReplacer(args, '%u%'); + expect(args['%u%']).toBe(''); + }); + + test('returns non-empty string in returnNull mode when content remains', () => { + expect(inputReplacer({'%x%': 'kept'}, '%x%', true)).toBe('kept'); + }); + + test('input with no placeholders is returned verbatim', () => { + expect(inputReplacer({'%a%': 'X'}, 'plain text')).toBe('plain text'); + }); +}); \ No newline at end of file diff --git a/tests/helpers/helpers.randomDistribution.test.js b/tests/helpers/helpers.randomDistribution.test.js new file mode 100644 index 00000000..ebfc71ca --- /dev/null +++ b/tests/helpers/helpers.randomDistribution.test.js @@ -0,0 +1,220 @@ +/* + * Randomness / fairness tests for the RNG primitives in src/functions/helpers.js: + * randomIntFromInterval, randomElementFromArray, shuffleArray, randomString. + * + * The helpers use crypto.randomInt (secure, unbiased) under the hood. Two + * complementary techniques are used: + * 1. Deterministic boundary/property tests pin crypto.randomInt at its lowest + * and highest legal return value to PROVE inclusive bounds and the absence + * of off-by-one / out-of-range bugs. These never flake. + * 2. Statistical fairness tests run the REAL (unmocked) crypto RNG over a large + * N and assert distribution properties with deliberately loose tolerances. + * Each such test carries a comment explaining why a false failure is + * astronomically unlikely. + */ +const crypto = require('crypto'); +const { + randomIntFromInterval, + randomElementFromArray, + shuffleArray, + randomString +} = require('../../src/functions/helpers'); + +afterEach(() => jest.restoreAllMocks()); + +// crypto.randomInt shapes: randomInt(max) -> [0,max-1]; randomInt(min,maxEx) -> [min,maxEx-1]. +const MIN = (a, b) => (b === undefined ? 0 : a); // lowest legal value +const MAX = (a, b) => (b === undefined ? a - 1 : b - 1); // highest legal value + +describe('randomIntFromInterval - boundary / off-by-one', () => { + test('lowest draw yields exactly the lower bound', () => { + jest.spyOn(crypto, 'randomInt').mockImplementation(MIN); + expect(randomIntFromInterval(1, 6)).toBe(1); + expect(randomIntFromInterval(0, 0)).toBe(0); + expect(randomIntFromInterval(-3, 3)).toBe(-3); + }); + + test('highest draw yields exactly the upper bound', () => { + jest.spyOn(crypto, 'randomInt').mockImplementation(MAX); + expect(randomIntFromInterval(1, 6)).toBe(6); + expect(randomIntFromInterval(-3, 3)).toBe(3); + expect(randomIntFromInterval(10, 10)).toBe(10); + }); + + test('min===max always returns that single value without drawing', () => { + const spy = jest.spyOn(crypto, 'randomInt'); + expect(randomIntFromInterval(7, 7)).toBe(7); + expect(spy).not.toHaveBeenCalled(); + }); + + test('every face of a d6 is reachable and never 0 or 7 (deterministic sweep)', () => { + // Feed each legal in-range result; the helper returns crypto.randomInt(1,7) + // straight through, so we prove every face 1..6 maps correctly and the + // extremes are reachable. + const queue = [1, 2, 3, 4, 5, 6, 6, 1]; + let i = 0; + jest.spyOn(crypto, 'randomInt').mockImplementation(() => queue[i++ % queue.length]); + const seen = new Set(); + for (let k = 0; k < queue.length; k++) { + const v = randomIntFromInterval(1, 6); + expect(v).toBeGreaterThanOrEqual(1); + expect(v).toBeLessThanOrEqual(6); + seen.add(v); + } + expect(seen.has(1)).toBe(true); + expect(seen.has(6)).toBe(true); + }); + + test('statistical: a d6 over 120k rolls covers all faces and stays roughly uniform', () => { + // N = 120_000, k = 6 buckets => expected 20_000 each. We only require every + // face to appear and each count within +/-25% of expectation. With sigma ~= 129, + // a 25% (5000-count) deviation is ~39 standard deviations away; the chance of + // a false failure is far below 1e-100, so this cannot realistically flake. + const N = 120_000; + const counts = [0, 0, 0, 0, 0, 0, 0, 0]; + for (let i = 0; i < N; i++) { + const v = randomIntFromInterval(1, 6); + expect(v).toBeGreaterThanOrEqual(1); + expect(v).toBeLessThanOrEqual(6); + counts[v]++; + } + expect(counts[0]).toBe(0); // never below the range + expect(counts[7]).toBe(0); // never above the range + const expected = N / 6; + for (let face = 1; face <= 6; face++) { + expect(counts[face]).toBeGreaterThan(expected * 0.75); + expect(counts[face]).toBeLessThan(expected * 1.25); + } + }); +}); + +describe('randomElementFromArray - boundary / short-circuits', () => { + test('empty array returns null', () => { + expect(randomElementFromArray([])).toBeNull(); + }); + + test('single-element array short-circuits to that element (no draw)', () => { + const spy = jest.spyOn(crypto, 'randomInt'); + expect(randomElementFromArray(['only'])).toBe('only'); + expect(spy).not.toHaveBeenCalled(); + }); + + test('index 0 picks the first element; the last index picks the last', () => { + const arr = ['a', 'b', 'c', 'd']; + jest.spyOn(crypto, 'randomInt').mockImplementation(MIN); + expect(randomElementFromArray(arr)).toBe('a'); + crypto.randomInt.mockImplementation(MAX); + expect(randomElementFromArray(arr)).toBe('d'); // never out of bounds + }); + + test('statistical: every index of a 5-element array is reachable and ~uniform', () => { + // N = 100_000, k = 5 => expected 20_000 each. Requiring counts within +/-25% + // (a 5000 deviation) when sigma ~= 126 means a ~39-sigma event would be needed + // to fail; false-failure probability is negligible (<<1e-100). + const arr = ['a', 'b', 'c', 'd', 'e']; + const N = 100_000; + const counts = { + a: 0, + b: 0, + c: 0, + d: 0, + e: 0 + }; + for (let i = 0; i < N; i++) counts[randomElementFromArray(arr)]++; + const expected = N / arr.length; + for (const key of arr) { + expect(counts[key]).toBeGreaterThan(0); + expect(counts[key]).toBeGreaterThan(expected * 0.75); + expect(counts[key]).toBeLessThan(expected * 1.25); + } + }); +}); + +describe('shuffleArray', () => { + test('returns a permutation (same multiset) and does not mutate the input', () => { + const input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const snapshot = [...input]; + for (let i = 0; i < 1000; i++) { + const out = shuffleArray(input); + expect(out).toHaveLength(input.length); + expect([...out].sort((a, b) => a - b)).toEqual(snapshot); + } + // Contract: input is copied, never mutated. + expect(input).toEqual(snapshot); + }); + + test('handles empty and single-element arrays', () => { + expect(shuffleArray([])).toEqual([]); + expect(shuffleArray([42])).toEqual([42]); + }); + + test('statistical: no positional bias - every element reaches every position', () => { + // 6 elements, N = 60_000 shuffles => each (element, position) pair expected + // 10_000 times. We only require every pair to occur at least once and land + // within +/-25% of expectation. sigma ~= 91 per cell, so a 2500 deviation is + // ~27 sigma; a false failure is astronomically unlikely (<<1e-100). This also + // catches the classic biased-shuffle bug where index 0 or the last index is + // disproportionately likely to stay put. + const base = ['x0', 'x1', 'x2', 'x3', 'x4', 'x5']; + const N = 60_000; + const grid = base.map(() => ({ + x0: 0, + x1: 0, + x2: 0, + x3: 0, + x4: 0, + x5: 0 + })); + for (let i = 0; i < N; i++) { + const out = shuffleArray(base); + for (let pos = 0; pos < out.length; pos++) grid[pos][out[pos]]++; + } + const expected = N / base.length; + for (let pos = 0; pos < base.length; pos++) { + for (const el of base) { + expect(grid[pos][el]).toBeGreaterThan(0); + expect(grid[pos][el]).toBeGreaterThan(expected * 0.75); + expect(grid[pos][el]).toBeLessThan(expected * 1.25); + } + } + }); +}); + +describe('randomString', () => { + test('boundary: length 0 returns empty string', () => { + expect(randomString(0)).toBe(''); + }); + + test('lowest draw selects the first charset char; the highest selects the last', () => { + jest.spyOn(crypto, 'randomInt').mockImplementation(MIN); + expect(randomString(5, 'ABCDE')).toBe('AAAAA'); + crypto.randomInt.mockImplementation(MAX); + expect(randomString(5, 'ABCDE')).toBe('EEEEE'); // last char, never out of range + }); + + test('output has the requested length and uses only the charset', () => { + expect(randomString(256)).toHaveLength(256); + expect(randomString(500, 'AB')).toMatch(/^[AB]+$/); + }); + + test('statistical: char distribution over a long string is roughly uniform', () => { + // A 100_000-char string over a 10-char alphabet => expected 10_000 per char. + // Requiring each within +/-25% (sigma ~= 95) means a ~26-sigma deviation would + // be needed to fail; false-failure probability is negligible (<<1e-100). + const charset = '0123456789'; + const N = 100_000; + const out = randomString(N, charset); + expect(out).toHaveLength(N); + const counts = {}; + for (const ch of charset) counts[ch] = 0; + for (const ch of out) { + expect(charset.includes(ch)).toBe(true); // only expected charset, never undefined + counts[ch]++; + } + const expected = N / charset.length; + for (const ch of charset) { + expect(counts[ch]).toBeGreaterThan(expected * 0.75); + expect(counts[ch]).toBeLessThan(expected * 1.25); + } + }); +}); \ No newline at end of file diff --git a/tests/helpers/helpers.randomSeeded.test.js b/tests/helpers/helpers.randomSeeded.test.js new file mode 100644 index 00000000..83a64b90 --- /dev/null +++ b/tests/helpers/helpers.randomSeeded.test.js @@ -0,0 +1,147 @@ +/* + * Deterministic-behavior tests for the randomness helpers. The helpers now use + * crypto.randomInt (cryptographically secure, unbiased) instead of Math.random, + * so we pin crypto.randomInt via a spy to assert the EXACT element/index/char + * each function picks and the Fisher-Yates swap order. + * + * crypto.randomInt has two call shapes the helpers use: + * randomInt(max) -> integer in [0, max-1] (single arg) + * randomInt(min, maxEx) -> integer in [min, maxEx-1] (two args) + * The MIN/MAX helpers below return the lowest / highest legal value for either + * shape, which is how we prove inclusive bounds without off-by-one. + */ +const crypto = require('crypto'); +const { + randomIntFromInterval, + randomElementFromArray, + shuffleArray, + randomString +} = require('../../src/functions/helpers'); + +afterEach(() => jest.restoreAllMocks()); + +function mockInt(fn) { + return jest.spyOn(crypto, 'randomInt').mockImplementation(fn); +} + +const MIN = (a, b) => (b === undefined ? 0 : a); // lowest value in range +const MAX = (a, b) => (b === undefined ? a - 1 : b - 1); // highest value in range + +describe('randomIntFromInterval (seeded)', () => { + test('lowest draw yields min', () => { + mockInt(MIN); + expect(randomIntFromInterval(3, 7)).toBe(3); + }); + + test('highest draw yields max', () => { + mockInt(MAX); + expect(randomIntFromInterval(3, 7)).toBe(7); + }); + + test('a specific draw maps straight through', () => { + mockInt(() => 5); + expect(randomIntFromInterval(3, 7)).toBe(5); + }); + + test('supports negative ranges', () => { + mockInt(MIN); + expect(randomIntFromInterval(-10, -5)).toBe(-10); + jest.restoreAllMocks(); + mockInt(MAX); + expect(randomIntFromInterval(-10, -5)).toBe(-5); + }); + + test('spanning zero returns 0 at the right draw', () => { + mockInt(() => 0); + expect(randomIntFromInterval(-2, 2)).toBe(0); + }); + + test('min===max returns that value without drawing', () => { + const spy = mockInt(() => 999); + expect(randomIntFromInterval(7, 7)).toBe(7); + expect(spy).not.toHaveBeenCalled(); + }); +}); + +describe('randomElementFromArray (seeded)', () => { + test('index 0 returns first element', () => { + mockInt(MIN); + expect(randomElementFromArray(['a', 'b', 'c', 'd'])).toBe('a'); + }); + + test('last index returns last element', () => { + mockInt(MAX); + expect(randomElementFromArray(['a', 'b', 'c', 'd'])).toBe('d'); + }); + + test('a middle index selects the middle element', () => { + mockInt(() => 2); + expect(randomElementFromArray(['a', 'b', 'c', 'd'])).toBe('c'); + }); + + test('single-element array short-circuits without drawing', () => { + const spy = mockInt(() => 0); + expect(randomElementFromArray(['only'])).toBe('only'); + expect(spy).not.toHaveBeenCalled(); + }); + + test('empty array short-circuits to null without drawing', () => { + const spy = mockInt(() => 0); + expect(randomElementFromArray([])).toBeNull(); + expect(spy).not.toHaveBeenCalled(); + }); +}); + +describe('shuffleArray (seeded Fisher-Yates)', () => { + test('all-zero draws rotate elements predictably', () => { + // j=0 every iteration so each element i swaps with index 0: + // [1,2,3,4] -> i3 swap(3,0) [4,2,3,1] -> i2 swap(2,0) [3,2,4,1] + // -> i1 swap(1,0) [2,3,4,1] -> i0 swap(0,0) [2,3,4,1] + mockInt(MIN); + expect(shuffleArray([1, 2, 3, 4])).toEqual([2, 3, 4, 1]); + }); + + test('identity permutation when each j equals i (highest draw)', () => { + // randomInt(i+1) returning its max (i) swaps every element with itself. + mockInt(MAX); + expect(shuffleArray([1, 2, 3, 4])).toEqual([1, 2, 3, 4]); + }); + + test('does not mutate input', () => { + mockInt(MIN); + const input = [1, 2, 3, 4]; + shuffleArray(input); + expect(input).toEqual([1, 2, 3, 4]); + }); + + test('empty and single-element arrays pass through', () => { + mockInt(MIN); + expect(shuffleArray([])).toEqual([]); + expect(shuffleArray([99])).toEqual([99]); + }); +}); + +describe('randomString (seeded)', () => { + test('lowest draw always picks the first char of the charset', () => { + mockInt(MIN); + expect(randomString(5, 'XYZ')).toBe('XXXXX'); + }); + + test('alternating draws map to deterministic characters', () => { + const seq = [0, 1]; + let i = 0; + mockInt(() => seq[i++ % seq.length]); + expect(randomString(4, 'AB')).toBe('ABAB'); + }); + + test('highest draw selects the final char of the charset', () => { + mockInt(MAX); + expect(randomString(3, 'ABC')).toBe('CCC'); + }); + + test('length 0 returns empty without drawing', () => { + const spy = mockInt(() => 0); + expect(randomString(0, 'AB')).toBe(''); + expect(spy).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/helpers/pureHelpers.test.js b/tests/helpers/pureHelpers.test.js new file mode 100644 index 00000000..ba912190 --- /dev/null +++ b/tests/helpers/pureHelpers.test.js @@ -0,0 +1,110 @@ +const { + parseEmbedColor, + inputReplacer, + formatVoiceDuration, + formatDurationShort +} = require('../../src/functions/helpers'); + +describe('parseEmbedColor', () => { + test('resolves named colors to their numeric value', () => { + expect(parseEmbedColor('RED')).toBe(0xE74C3C); + expect(parseEmbedColor('BLURPLE')).toBe(0x5865F2); + }); + + test('returns numbers unchanged', () => { + expect(parseEmbedColor(0xff00ff)).toBe(0xff00ff); + }); + + test('parses leading-hash hex strings', () => { + expect(parseEmbedColor('#ff00ff')).toBe(0xff00ff); + expect(parseEmbedColor('#000001')).toBe(1); + }); + + test('parses bare hex strings', () => { + expect(parseEmbedColor('abcdef')).toBe(0xabcdef); + }); + + test('passes through non-string non-number values', () => { + expect(parseEmbedColor(null)).toBeNull(); + expect(parseEmbedColor(undefined)).toBeUndefined(); + }); +}); + +describe('inputReplacer', () => { + test('substitutes every key in the args map', () => { + expect(inputReplacer({ + '%name%': 'Alice', + '%score%': 42 + }, 'hi %name%, you scored %score%')).toBe('hi Alice, you scored 42'); + }); + + test('replaces all occurrences, not just the first', () => { + expect(inputReplacer({'%x%': '1'}, '%x%-%x%-%x%')).toBe('1-1-1'); + }); + + test('coerces non-string non-number arg values to empty string', () => { + expect(inputReplacer({'%foo%': null}, '[%foo%]')).toBe('[]'); + expect(inputReplacer({'%foo%': {a: 1}}, '[%foo%]')).toBe('[]'); + }); + + test('returns input unchanged when args is not an object', () => { + expect(inputReplacer('not an object', 'hello %name%')).toBe('hello %name%'); + }); + + test('returns null in returnNull mode for empty input', () => { + expect(inputReplacer({}, '', true)).toBeNull(); + expect(inputReplacer({}, null, true)).toBeNull(); + }); + + test('coerces missing input to empty string by default', () => { + expect(inputReplacer({'%a%': 'X'}, null)).toBe(''); + }); +}); + +describe('formatVoiceDuration', () => { + test('zero or negative becomes "0m"', () => { + expect(formatVoiceDuration(0)).toBe('helpers.voice-time-m(i=0)'); + expect(formatVoiceDuration(-5)).toBe('helpers.voice-time-m(i=0)'); + expect(formatVoiceDuration(Infinity)).toBe('helpers.voice-time-m(i=0)'); + }); + + test('seconds below a minute use the s key', () => { + expect(formatVoiceDuration(30)).toBe('helpers.voice-time-s(i=30)'); + }); + + test('minutes below an hour use the m key', () => { + expect(formatVoiceDuration(125)).toBe('helpers.voice-time-m(i=2)'); + expect(formatVoiceDuration(60)).toBe('helpers.voice-time-m(i=1)'); + }); + + test('an hour or more uses the hm key', () => { + expect(formatVoiceDuration(6125)).toBe('helpers.voice-time-hm(h=1,m=42)'); + expect(formatVoiceDuration(3600)).toBe('helpers.voice-time-hm(h=1,m=0)'); + }); +}); + +describe('formatDurationShort', () => { + test('sub-minute values return the just-now key', () => { + expect(formatDurationShort(0)).toBe('helpers.duration-just-now'); + expect(formatDurationShort(59_000)).toBe('helpers.duration-just-now'); + expect(formatDurationShort(NaN)).toBe('helpers.duration-just-now'); + }); + + test('uses singular keys when the value is 1', () => { + expect(formatDurationShort(60_000)).toBe('helpers.duration-minute(i=1)'); + expect(formatDurationShort(60 * 60_000)).toBe('helpers.duration-hour(i=1)'); + expect(formatDurationShort(24 * 60 * 60_000)).toBe('helpers.duration-day(i=1)'); + }); + + test('uses plural keys for >1', () => { + expect(formatDurationShort(5 * 60_000)).toBe('helpers.duration-minutes(i=5)'); + expect(formatDurationShort(3 * 60 * 60_000)).toBe('helpers.duration-hours(i=3)'); + }); + + test('picks the largest meaningful unit', () => { + const tenDays = 10 * 24 * 60 * 60_000; + expect(formatDurationShort(tenDays)).toBe('helpers.duration-days(i=10)'); + const twoMonths = 60 * 24 * 60 * 60_000; + expect(formatDurationShort(twoMonths)).toBe('helpers.duration-months(i=2)'); + }); +}); \ No newline at end of file diff --git a/tests/helpers/pureMisc.test.js b/tests/helpers/pureMisc.test.js new file mode 100644 index 00000000..fd35b301 --- /dev/null +++ b/tests/helpers/pureMisc.test.js @@ -0,0 +1,338 @@ +const helpers = require('../../src/functions/helpers'); +const {__test} = helpers; +const {ButtonStyle} = require('discord.js'); + +describe('asyncForEach', () => { + test('invokes callback for each element with (value, index, array)', async () => { + const calls = []; + const arr = ['a', 'b', 'c']; + await helpers.asyncForEach(arr, async (value, index, array) => { + calls.push({ + value, + index, + sameArray: array === arr + }); + }); + expect(calls).toEqual([ + { + value: 'a', + index: 0, + sameArray: true + }, + { + value: 'b', + index: 1, + sameArray: true + }, + { + value: 'c', + index: 2, + sameArray: true + } + ]); + }); + + test('awaits sequentially', async () => { + const log = []; + await helpers.asyncForEach([1, 2, 3], async (n) => { + await new Promise((r) => setTimeout(r, 1)); + log.push(n); + }); + expect(log).toEqual([1, 2, 3]); + }); + + test('returns undefined on empty array', async () => { + const result = await helpers.asyncForEach([], async () => { + throw new Error('should not run'); + }); + expect(result).toBeUndefined(); + }); +}); + +describe('formatDiscordUserName', () => { + test('returns tag for legacy discriminator users', () => { + expect(helpers.formatDiscordUserName({ + discriminator: '1234', + tag: 'Alice#1234', + username: 'Alice' + })).toBe('Alice#1234'); + }); + + test('falls back to username#discriminator when tag missing', () => { + expect(helpers.formatDiscordUserName({ + discriminator: '0042', + username: 'Bob' + })).toBe('Bob#0042'); + }); + + test('returns just the username for new-style "0" discriminator users', () => { + expect(helpers.formatDiscordUserName({ + discriminator: '0', + username: 'Charlie' + })).toBe('Charlie'); + }); +}); + +describe('truncate', () => { + test('passes through short strings', () => { + expect(helpers.truncate('hi', 10)).toBe('hi'); + expect(helpers.truncate('exactly10c', 10)).toBe('exactly10c'); + }); + + test('truncates with ellipsis at length', () => { + expect(helpers.truncate('hello world', 8)).toBe('hello...'); + }); + + test('trims whitespace before adding ellipsis', () => { + expect(helpers.truncate('foo bar baz qux', 8)).toBe('foo b...'); + }); + + test('returns falsy input unchanged', () => { + expect(helpers.truncate('', 10)).toBe(''); + expect(helpers.truncate(null, 10)).toBeNull(); + expect(helpers.truncate(undefined, 10)).toBeUndefined(); + }); +}); + +describe('pufferStringToSize', () => { + test('returns input unchanged when already at size', () => { + expect(helpers.pufferStringToSize('hi', 2)).toBe('hi'); + }); + + test('pads with non-breaking spaces alternating around the string', () => { + // size 5, input "hi" -> add 3 non-breaking spaces (\xa0) + // iter 0 (even) -> prepend; iter 1 (odd) -> append; iter 2 (even) -> prepend + const out = helpers.pufferStringToSize('hi', 5); + expect(out.length).toBe(5); + expect(out).toBe('\xa0\xa0hi\xa0'); + }); + + test('coerces non-string input via toString', () => { + const out = helpers.pufferStringToSize(42, 4); + expect(out.length).toBe(4); + expect(out.includes('42')).toBe(true); + }); +}); + +describe('compareArrays', () => { + test('different lengths are not equal', () => { + expect(helpers.compareArrays([1, 2], [1, 2, 3])).toBe(false); + }); + + test('same primitives in any order are equal', () => { + expect(helpers.compareArrays([1, 2, 3], [3, 2, 1])).toBe(true); + expect(helpers.compareArrays(['a', 'b'], ['b', 'a'])).toBe(true); + }); + + test('primitive mismatch is not equal', () => { + expect(helpers.compareArrays([1, 2, 3], [1, 2, 4])).toBe(false); + }); + + test('object arrays compared key-by-key', () => { + expect(helpers.compareArrays([{ + a: 1, + b: 2 + }], [{ + a: 1, + b: 2 + }])).toBe(true); + expect(helpers.compareArrays([{a: 1}], [{a: 2}])).toBe(false); + }); + + test('treats missing key as null when comparing', () => { + expect(helpers.compareArrays([{ + a: 1, + b: null + }], [{a: 1}])).toBe(true); + }); +}); + +describe('randomIntFromInterval', () => { + test('values stay within inclusive bounds', () => { + for (let i = 0; i < 200; i++) { + const n = helpers.randomIntFromInterval(3, 7); + expect(n).toBeGreaterThanOrEqual(3); + expect(n).toBeLessThanOrEqual(7); + expect(Number.isInteger(n)).toBe(true); + } + }); + + test('returns the bound when min === max', () => { + expect(helpers.randomIntFromInterval(5, 5)).toBe(5); + }); +}); + +describe('randomElementFromArray', () => { + test('returns null on empty', () => { + expect(helpers.randomElementFromArray([])).toBeNull(); + }); + + test('returns the only element when length is 1', () => { + expect(helpers.randomElementFromArray(['only'])).toBe('only'); + }); + + test('always returns an element from the input', () => { + const arr = ['a', 'b', 'c', 'd']; + for (let i = 0; i < 100; i++) { + expect(arr.includes(helpers.randomElementFromArray(arr))).toBe(true); + } + }); +}); + +describe('renderProgressbar', () => { + test('renders all-empty at 0 percent', () => { + expect(helpers.renderProgressbar(0, 10)).toBe('░░░░░░░░░░'); + }); + + test('renders all-full at 100 percent', () => { + expect(helpers.renderProgressbar(100, 10)).toBe('██████████'); + }); + + test('renders half-and-half at 50 percent', () => { + expect(helpers.renderProgressbar(50, 10)).toBe('██████████'); // 50 >= 5*10 = false but 50 >= 5*i for i<=10. Actually 5*10 = 50, condition >=, so i=10 included. + }); + + test('partial fill scales with percentage', () => { + // 25%: i=1..5 satisfy 25 >= 5*i (5,10,15,20,25); i=6..20 do not + expect(helpers.renderProgressbar(25, 20)).toBe('█████░░░░░░░░░░░░░░░'); + }); + + test('uses default length of 20', () => { + expect(helpers.renderProgressbar(100)).toHaveLength(20); + }); +}); + +describe('shuffleArray', () => { + test('returns a new array with the same elements', () => { + const input = [1, 2, 3, 4, 5]; + const out = helpers.shuffleArray(input); + expect(out).not.toBe(input); // new array reference + expect(out.sort()).toEqual([1, 2, 3, 4, 5]); + }); + + test('does not mutate the input', () => { + const input = [1, 2, 3]; + helpers.shuffleArray(input); + expect(input).toEqual([1, 2, 3]); + }); + + test('shuffles (extremely high probability across 5! permutations)', () => { + const input = [1, 2, 3, 4, 5]; + let differed = false; + for (let i = 0; i < 50; i++) { + const out = helpers.shuffleArray(input); + if (out.some((v, idx) => v !== input[idx])) { + differed = true; + break; + } + } + expect(differed).toBe(true); + }); +}); + +describe('hashMD5', () => { + test('matches the canonical RFC 1321 vectors', () => { + expect(helpers.hashMD5('')).toBe('d41d8cd98f00b204e9800998ecf8427e'); + expect(helpers.hashMD5('abc')).toBe('900150983cd24fb0d6963f7d28e17f72'); + }); + + test('is deterministic for the same input', () => { + expect(helpers.hashMD5('hello')).toBe(helpers.hashMD5('hello')); + }); +}); + +describe('mapButtonStyle (internal)', () => { + test('maps each integer 1-5 to the matching ButtonStyle', () => { + expect(__test.mapButtonStyle(1)).toBe(ButtonStyle.Primary); + expect(__test.mapButtonStyle(2)).toBe(ButtonStyle.Secondary); + expect(__test.mapButtonStyle(3)).toBe(ButtonStyle.Success); + expect(__test.mapButtonStyle(4)).toBe(ButtonStyle.Danger); + expect(__test.mapButtonStyle(5)).toBe(ButtonStyle.Link); + }); + + test('falls back to Secondary for unknown values', () => { + expect(__test.mapButtonStyle(0)).toBe(ButtonStyle.Secondary); + expect(__test.mapButtonStyle(99)).toBe(ButtonStyle.Secondary); + expect(__test.mapButtonStyle(null)).toBe(ButtonStyle.Secondary); + }); +}); + +describe('formatV4BuilderError (internal)', () => { + test('flattens a CombinedPropertyError-style nested errors array', () => { + const err = { + errors: [ + ['label', { + message: 'must be a string', + given: 42 + }], + ['style', {message: 'invalid'}] + ] + }; + expect(__test.formatV4BuilderError(err)).toBe('label: must be a string (got 42); style: invalid'); + }); + + test('falls back to a single-message format with extras', () => { + const err = { + message: 'value out of range', + constraint: 'NumberMax', + given: 10, + expected: 5 + }; + expect(__test.formatV4BuilderError(err)).toBe('value out of range [NumberMax] (got 10) expected: 5'); + }); + + test('handles minimal error objects (message only)', () => { + expect(__test.formatV4BuilderError({message: 'oops'})).toBe('oops'); + }); + + test('joins array-valued expected with commas', () => { + const err = { + message: 'bad', + expected: ['a', 'b', 'c'] + }; + expect(__test.formatV4BuilderError(err)).toBe('bad expected: a, b, c'); + }); +}); + +describe('moduleEnabled', () => { + test('returns true when module is registered and enabled', () => { + const client = {modules: {foo: {enabled: true}}}; + expect(helpers.moduleEnabled(client, 'foo')).toBe(true); + }); + + test('returns false when module exists but is disabled', () => { + const client = {modules: {foo: {enabled: false}}}; + expect(helpers.moduleEnabled(client, 'foo')).toBe(false); + }); + + test('returns false when module is absent', () => { + const client = {modules: {}}; + expect(helpers.moduleEnabled(client, 'foo')).toBe(false); + }); +}); + +describe('formatNumber', () => { + test('formats a number with the client locale', () => { + const stub = require('../__stubs__/main'); + stub.client.bcp47Locale = 'en-US'; + expect(helpers.formatNumber(1234567)).toBe('1,234,567'); + }); + + test('coerces numeric strings before formatting', () => { + const stub = require('../__stubs__/main'); + stub.client.bcp47Locale = 'en-US'; + expect(helpers.formatNumber('1234.5')).toBe('1,234.5'); + }); + + test('passes Intl options through', () => { + const stub = require('../__stubs__/main'); + stub.client.bcp47Locale = 'en-US'; + expect(helpers.formatNumber(0.5, {style: 'percent'})).toBe('50%'); + }); +}); + +describe('checkForUpdates', () => { + test('is a no-op and resolves', async () => { + await expect(helpers.checkForUpdates()).resolves.toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/tests/helpers/randomString.test.js b/tests/helpers/randomString.test.js new file mode 100644 index 00000000..ea3d6451 --- /dev/null +++ b/tests/helpers/randomString.test.js @@ -0,0 +1,34 @@ +const {randomString} = require('../../src/functions/helpers'); + +describe('randomString', () => { + test('returns a string of the requested length', () => { + expect(randomString(0)).toBe(''); + expect(randomString(1)).toHaveLength(1); + expect(randomString(32)).toHaveLength(32); + expect(randomString(200)).toHaveLength(200); + }); + + test('default charset only contains alphanumerics', () => { + const out = randomString(1000); + expect(out).toMatch(/^[A-Za-z0-9]+$/); + }); + + test('honors a custom charset', () => { + const out = randomString(500, 'AB'); + expect(out).toMatch(/^[AB]+$/); + // Both characters should appear in 500 draws with overwhelming probability. + expect(out.includes('A')).toBe(true); + expect(out.includes('B')).toBe(true); + }); + + test('single-character charset returns that character repeated', () => { + expect(randomString(10, 'x')).toBe('xxxxxxxxxx'); + }); + + test('produces different output on successive calls', () => { + // 64 chars from 62-char alphabet collide with vanishing probability. + const a = randomString(64); + const b = randomString(64); + expect(a).not.toBe(b); + }); +}); diff --git a/tests/helpers/sideEffects.test.js b/tests/helpers/sideEffects.test.js new file mode 100644 index 00000000..4dcd6971 --- /dev/null +++ b/tests/helpers/sideEffects.test.js @@ -0,0 +1,280 @@ +const mainStub = require('../__stubs__/main'); +const helpers = require('../../src/functions/helpers'); + +function resetClient() { + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'test-footer', + footerImgUrl: '', + disableFooterTimestamp: false + }; + mainStub.client.scnxSetup = false; + mainStub.client.user = null; + mainStub.client.guild = null; + mainStub.client.modules = {}; + mainStub.client.logger = { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + log: jest.fn() + }; + mainStub.client.logChannel = null; + mainStub.client.models = {}; + mainStub.client.error = jest.fn(); +} + +beforeEach(resetClient); + +describe('disableModule', () => { + test('flips enabled to false and logs', () => { + mainStub.client.modules.foo = {enabled: true}; + helpers.disableModule('foo', 'broken config'); + expect(mainStub.client.modules.foo.enabled).toBe(false); + expect(mainStub.client.logger.error).toHaveBeenCalled(); + }); + + test('throws when the module was never loaded', () => { + expect(() => helpers.disableModule('missing')).toThrow(/never loaded/); + }); + + test('also sends to logChannel when present', () => { + const send = jest.fn().mockResolvedValue(); + mainStub.client.modules.foo = {enabled: true}; + mainStub.client.logChannel = {send}; + helpers.disableModule('foo', 'reason'); + expect(send).toHaveBeenCalled(); + }); +}); + +describe('migrate', () => { + test('is a no-op when oldModel has no rows', async () => { + const oldFindAll = jest.fn().mockResolvedValue([]); + const newCreate = jest.fn(); + mainStub.client.models.m = { + old: {findAll: oldFindAll}, + new: {create: newCreate} + }; + await helpers.migrate('m', 'old', 'new'); + expect(oldFindAll).toHaveBeenCalled(); + expect(newCreate).not.toHaveBeenCalled(); + }); + + test('copies each row to new model and destroys the source', async () => { + const destroy1 = jest.fn().mockResolvedValue(); + const destroy2 = jest.fn().mockResolvedValue(); + const row1 = { + dataValues: { + id: 1, + name: 'a', + createdAt: 'x', + updatedAt: 'y' + }, + destroy: destroy1 + }; + const row2 = { + dataValues: { + id: 2, + name: 'b' + }, + destroy: destroy2 + }; + const newCreate = jest.fn().mockResolvedValue(); + mainStub.client.models.m = { + old: {findAll: jest.fn().mockResolvedValue([row1, row2])}, + new: {create: newCreate} + }; + await helpers.migrate('m', 'old', 'new'); + expect(newCreate).toHaveBeenCalledTimes(2); + expect(newCreate).toHaveBeenCalledWith({ + id: 1, + name: 'a' + }); + expect(newCreate).toHaveBeenCalledWith({ + id: 2, + name: 'b' + }); + expect(destroy1).toHaveBeenCalled(); + expect(destroy2).toHaveBeenCalled(); + }); +}); + +describe('tryArchiveDiscordAttachment', () => { + test('returns null when client.scnxSetup is false', async () => { + const result = await helpers.tryArchiveDiscordAttachment({scnxSetup: false}, 'https://x/img.png'); + expect(result).toBeNull(); + }); +}); + +describe('archiveDiscordAttachment', () => { + test('returns the original URL when scnxSetup is false', async () => { + const url = 'https://cdn.discordapp.com/attachments/1/2/file.png'; + const result = await helpers.archiveDiscordAttachment({scnxSetup: false}, url); + expect(result).toBe(url); + }); +}); + +/* + * Tests below exercise paste-network paths and re-import helpers/centra per test. + * Live in their own describe block so they can use jest.isolateModules without + * disturbing the shared module instance used above. + */ +describe('messageLogToStringToPaste', () => { + function mockCentraOk(jsonBody) { + jest.doMock('centra', () => () => ({ + header: function () { + return this; + }, + body: function () { + return this; + }, + send: async () => ({ + statusCode: 200, + headers: {}, + json: async () => jsonBody + }) + })); + } + + test('formats messages into a log block and uploads via paste', async () => { + await jest.isolateModulesAsync(async () => { + mockCentraOk({ + status: 0, + id: 'abc123', + url: '/?abc123' + }); + const {messageLogToStringToPaste} = require('../../src/functions/helpers'); + const messages = [ + { + id: '1', + author: { + bot: false, + tag: 'Alice#0001', + username: 'Alice', + discriminator: '0001', + id: 'a-id' + }, + content: 'first' + }, + { + id: '2', + author: { + bot: true, + tag: 'Bot#0000', + username: 'Bot', + discriminator: '0000', + id: 'b-id' + }, + content: 'second' + } + ]; + const channel = { + id: 'ch-1', + name: 'general', + messages: {fetch: jest.fn().mockResolvedValue({forEach: (cb) => messages.forEach(cb)})} + }; + const url = await messageLogToStringToPaste(channel, 50); + expect(url).toMatch(/^https:\/\/paste\.scootkit\.com\/\?abc123#/); + expect(channel.messages.fetch).toHaveBeenCalledWith({limit: 50}); + }); + }); + + test('caps fetch limit at 100', async () => { + await jest.isolateModulesAsync(async () => { + mockCentraOk({ + status: 0, + url: '/?x' + }); + const {messageLogToStringToPaste} = require('../../src/functions/helpers'); + const channel = { + id: 'c', + name: 'n', + messages: { + fetch: jest.fn().mockResolvedValue({ + forEach: () => { + } + }) + } + }; + await messageLogToStringToPaste(channel, 500); + expect(channel.messages.fetch).toHaveBeenCalledWith({limit: 100}); + }); + }); +}); + +describe('postToSCNetworkPaste end-to-end (mocked centra)', () => { + test('returns full URL with base58 key fragment on success', async () => { + await jest.isolateModulesAsync(async () => { + jest.doMock('centra', () => () => ({ + header: function () { + return this; + }, + body: function () { + return this; + }, + send: async () => ({ + statusCode: 200, + headers: {}, + json: async () => ({ + status: 0, + url: '/?paste-id' + }) + }) + })); + const {postToSCNetworkPaste} = require('../../src/functions/helpers'); + const url = await postToSCNetworkPaste('hello'); + expect(url).toMatch(/^https:\/\/paste\.scootkit\.com\/\?paste-id#[1-9A-HJ-NP-Za-km-z]+$/); + }); + }); + + test('throws PasteUploadError on permanent server rejection', async () => { + await jest.isolateModulesAsync(async () => { + jest.doMock('centra', () => () => ({ + header: function () { + return this; + }, + body: function () { + return this; + }, + send: async () => ({ + statusCode: 200, + headers: {}, + json: async () => ({ + status: 1, + message: 'Paste size invalid' + }) + }) + })); + const { + postToSCNetworkPaste, + PasteUploadError + } = require('../../src/functions/helpers'); + await expect(postToSCNetworkPaste('x')).rejects.toBeInstanceOf(PasteUploadError); + }); + }); + + test('throws PasteUploadError on persistent HTTP 4xx', async () => { + await jest.isolateModulesAsync(async () => { + jest.doMock('centra', () => () => ({ + header: function () { + return this; + }, + body: function () { + return this; + }, + send: async () => ({ + statusCode: 413, + headers: {}, + json: async () => ({}) + }) + })); + const { + postToSCNetworkPaste, + PasteUploadError + } = require('../../src/functions/helpers'); + await expect(postToSCNetworkPaste('x')).rejects.toBeInstanceOf(PasteUploadError); + }); + }); +}); \ No newline at end of file diff --git a/tests/info-commands/legacyChannelType.test.js b/tests/info-commands/legacyChannelType.test.js new file mode 100644 index 00000000..49110c17 --- /dev/null +++ b/tests/info-commands/legacyChannelType.test.js @@ -0,0 +1,57 @@ +/* + * Tests for legacyChannelType in modules/info-commands/commands/info.js, which + * maps discord.js v14 numeric ChannelType values back to the v13 string names + * the /info channel embed localizes against. Also covers the passthrough for + * already-string inputs and the beforeSubcommand defer, plus the user-not-found + * branch of the user subcommand. + */ + +const {ChannelType} = require('discord.js'); +const info = require('../../modules/info-commands/commands/info'); +const {legacyChannelType} = info; + +describe('legacyChannelType', () => { + test('maps text/voice/category numeric types', () => { + expect(legacyChannelType(ChannelType.GuildText)).toBe('GUILD_TEXT'); + expect(legacyChannelType(ChannelType.GuildVoice)).toBe('GUILD_VOICE'); + expect(legacyChannelType(ChannelType.GuildCategory)).toBe('GUILD_CATEGORY'); + }); + + test('maps announcement and thread types', () => { + expect(legacyChannelType(ChannelType.GuildAnnouncement)).toBe('GUILD_NEWS'); + expect(legacyChannelType(ChannelType.PublicThread)).toBe('PUBLIC_THREAD'); + expect(legacyChannelType(ChannelType.PrivateThread)).toBe('PRIVATE_THREAD'); + expect(legacyChannelType(ChannelType.AnnouncementThread)).toBe('NEWS_THREAD'); + }); + + test('maps forum, media and stage types', () => { + expect(legacyChannelType(ChannelType.GuildForum)).toBe('GUILD_FORUM'); + expect(legacyChannelType(ChannelType.GuildMedia)).toBe('GUILD_MEDIA'); + expect(legacyChannelType(ChannelType.GuildStageVoice)).toBe('GUILD_STAGE_VOICE'); + }); + + test('passes through values that are already strings', () => { + expect(legacyChannelType('GUILD_TEXT')).toBe('GUILD_TEXT'); + }); +}); + +describe('beforeSubcommand', () => { + test('defers the reply ephemerally', async () => { + const interaction = {deferReply: jest.fn().mockResolvedValue()}; + await info.beforeSubcommand(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ephemeral: true}); + }); +}); + +describe('user subcommand - not found', () => { + test('replies with user_not_found when no member resolves', async () => { + const interaction = { + client: {configurations: {'info-commands': {strings: {user_not_found: 'no-user'}}}}, + options: {getMember: () => null}, + member: null, + reply: jest.fn().mockResolvedValue() + }; + await info.subcommands.user(interaction); + expect(interaction.reply.mock.calls[0][0].content).toBe('no-user'); + }); +}); diff --git a/tests/info-commands/serverSubcommand.test.js b/tests/info-commands/serverSubcommand.test.js new file mode 100644 index 00000000..7251d674 --- /dev/null +++ b/tests/info-commands/serverSubcommand.test.js @@ -0,0 +1,198 @@ +/* + * Tests for the /info server subcommand (modules/info-commands/commands/info.js). + * It assembles a server-overview embed: owner (via fetchOwner), ban count (via + * bans.fetch), member/channel tables, and a guild-features list. Covers the + * happy path field assembly, the optional afk/description/rules/system fields, + * and the "no features" fallback. MessageEmbed + helpers mocked. + */ +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn(), + pufferStringToSize: (s) => String(s), + dateToDiscordTimestamp: (d) => ``, + formatDiscordUserName: (u) => u.username, + formatNumber: (n) => String(n), + parseEmbedColor: (c) => c, + safeSetFooter: jest.fn(), + moduleEnabled: () => false +})); +jest.mock('discord.js', () => { + const actual = jest.requireActual('discord.js'); + + class MessageEmbed { + constructor() { + this.fields = []; + this.data = {}; + } + + setTitle(t) { + this.data.title = t; + return this; + } + + setColor(c) { + this.data.color = c; + return this; + } + + setThumbnail(t) { + this.data.thumbnail = t; + return this; + } + + setImage(i) { + this.data.image = i; + return this; + } + + setDescription(d) { + this.data.description = d; + return this; + } + + setTimestamp() { + this.data.timestamp = true; + return this; + } + + addField(name, value, inline) { + this.fields.push({ + name, + value, + inline + }); + return this; + } + } + + return { + ChannelType: actual.ChannelType, + MessageEmbed + }; +}); + +const {ChannelType} = require('discord.js'); +const info = require('../../modules/info-commands/commands/info'); + +const strings = { + serverinfo: { + afkChannel: 'AFK', + id: 'Id', + owner: 'Owner', + boosts: 'Boosts', + emojiCount: 'Emojis', + stickerCount: 'Stickers', + roleCount: 'Roles', + rulesChannel: 'Rules', + dcSystemChannel: 'System', + verificationLevel: 'Verify', + banCount: 'Bans', + createdAt: 'Created', + members: 'Members', + channels: 'Channels', + features: 'Features', + noFeaturesEnabled: 'NoFeatures' + } +}; + +function channels(list) { + const map = new Map(list.map((v, i) => [String(i), v])); + map.filter = (fn) => { + const m = new Map([...map].filter(([, v]) => fn(v))); + m.filter = map.filter; + return m; + }; + return map; +} + +function makeGuild(over = {}) { + return { + id: 'g1', + name: 'My Server', + iconURL: () => 'icon', + bannerURL: () => 'banner', + afkChannel: null, + afkChannelID: null, + afkTimeout: 300, + description: null, + premiumTier: 2, + premiumSubscriptionCount: 10, + emojis: {cache: {size: 5}}, + stickers: {cache: {size: 0}}, + roles: {cache: {size: 8}}, + rulesChannelID: null, + systemChannelID: null, + verificationLevel: 1, + bans: {fetch: jest.fn().mockResolvedValue({size: 3})}, + createdAt: new Date('2020-01-01'), + fetchOwner: jest.fn().mockResolvedValue({id: 'owner1'}), + members: { + cache: channels([{ + user: {bot: false}, + presence: {status: 'online'} + }, { + user: {bot: true}, + presence: null + }]) + }, + channels: {cache: channels([{type: ChannelType.GuildText}, {type: ChannelType.GuildVoice}])}, + features: [], + ...over + }; +} + +function makeInteraction(guild) { + return { + client: { + configurations: {'info-commands': {strings}}, + strings: {disableFooterTimestamp: true} + }, + guild, + editReply: jest.fn().mockResolvedValue() + }; +} + +test('builds the overview with owner, bans and member/channel tables', async () => { + const guild = makeGuild(); + const interaction = makeInteraction(guild); + await info.subcommands.server(interaction); + expect(guild.fetchOwner).toHaveBeenCalled(); + expect(guild.bans.fetch).toHaveBeenCalled(); + const embed = interaction.editReply.mock.calls[0][0].embeds[0]; + const names = embed.fields.map(f => f.name); + expect(names).toEqual(expect.arrayContaining(['Id', 'Owner', 'Bans', 'Members', 'Channels', 'Features'])); + expect(embed.fields.find(f => f.name === 'Bans').value).toBe('3'); +}); + +test('includes optional afk/description/rules/system fields when present', async () => { + const guild = makeGuild({ + afkChannel: {}, + afkChannelID: 'afk1', + description: 'A cool place', + rulesChannelID: 'rules1', + systemChannelID: 'sys1' + }); + const interaction = makeInteraction(guild); + await info.subcommands.server(interaction); + const embed = interaction.editReply.mock.calls[0][0].embeds[0]; + expect(embed.data.description).toBe('A cool place'); + const names = embed.fields.map(f => f.name); + expect(names).toEqual(expect.arrayContaining(['AFK', 'Rules', 'System'])); +}); + +test('uses the no-features fallback when the guild has no features', async () => { + const guild = makeGuild({features: []}); + const interaction = makeInteraction(guild); + await info.subcommands.server(interaction); + const embed = interaction.editReply.mock.calls[0][0].embeds[0]; + const features = embed.fields.find(f => f.name === 'Features'); + expect(features.value).toContain('NoFeatures'); +}); + +test('renders a capitalized feature list when features exist', async () => { + const guild = makeGuild({features: ['COMMUNITY', 'BANNER']}); + const interaction = makeInteraction(guild); + await info.subcommands.server(interaction); + const features = interaction.editReply.mock.calls[0][0].embeds[0].fields.find(f => f.name === 'Features'); + expect(features.value).toContain('Community'); + expect(features.value).toContain('Banner'); +}); \ No newline at end of file diff --git a/tests/info-commands/subcommands.test.js b/tests/info-commands/subcommands.test.js new file mode 100644 index 00000000..58950ee4 --- /dev/null +++ b/tests/info-commands/subcommands.test.js @@ -0,0 +1,338 @@ +/* + * Tests for the /info subcommands (modules/info-commands/commands/info.js): + * - channel: type/name/id fields, thread-specific fields, and voice-member + * listing. + * - role: permission rendering (ADMINISTRATOR shorthand vs explicit list), + * small-member listing, and the hoist/mentionable/managed feature flags. + * - user: the levels enrichment block and the administrator permission + * shorthand. + * - server: owner/ban/member-table assembly. + * MessageEmbed + helpers are mocked so we can assert on the field set; the + * cross-module helpers (messageCreate) load via the curve config. + */ +const mainStub = require('../__stubs__/main'); + +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn((i) => ({_embedType: i})), + pufferStringToSize: (s) => String(s), + dateToDiscordTimestamp: (d) => ``, + formatDiscordUserName: (u) => u.username, + formatNumber: (n) => String(n), + parseEmbedColor: (c) => c, + safeSetFooter: jest.fn(), + moduleEnabled: (client, name) => !!(client.modules && client.modules[name] && client.modules[name].enabled) +})); +jest.mock('discord.js', () => { + const actual = jest.requireActual('discord.js'); + + class MessageEmbed { + constructor() { + this.fields = []; + this.data = {}; + } + + setTitle(t) { + this.data.title = t; + return this; + } + + setColor(c) { + this.data.color = c; + return this; + } + + setThumbnail(t) { + this.data.thumbnail = t; + return this; + } + + setImage(i) { + this.data.image = i; + return this; + } + + setDescription(d) { + this.data.description = d; + return this; + } + + setTimestamp() { + this.data.timestamp = true; + return this; + } + + addField(name, value, inline) { + this.fields.push({ + name, + value, + inline + }); + return this; + } + } + + return { + ChannelType: actual.ChannelType, + MessageEmbed + }; +}); + +const {ChannelType} = require('discord.js'); +const info = require('../../modules/info-commands/commands/info'); + +const strings = { + channelInfo: { + type: 'Type', + id: 'Id', + createdAt: 'Created', + name: 'Name', + parent: 'Parent', + position: 'Pos', + membersInChannel: 'Members', + threadOwner: 'Owner', + threadMessages: 'Msgs', + threadMemberCount: 'TMembers', + threadArchivedAt: 'Arch', + threadAutoArchiveDuration: 'AutoArch' + }, + roleInfo: { + createdAt: 'Created', + position: 'Pos', + id: 'Id', + name: 'Name', + color: 'Color', + memberWithThisRoleCount: 'Count', + memberWithThisRole: 'Who', + permissions: 'Perms' + }, + userinfo: { + tag: 'Tag', + id: 'Id', + createdAt: 'Created', + joinedAt: 'Joined', + xp: 'XP', + level: 'Level', + messages: 'Msgs', + permissions: 'Perms', + noPermissions: 'None', + 'invited-by': 'InvBy', + invites: 'Invites' + }, + user_not_found: 'no-user' +}; + +function clientBase(modules = {}) { + const conf = { + 'info-commands': {strings}, + levels: { + config: { + curveType: 'LINEAR', + maximumLevelEnabled: false, + startFromZero: false + } + } + }; + mainStub.client.configurations = conf; + return { + configurations: conf, + modules, + strings: {disableFooterTimestamp: true}, + locale: 'en' + }; +} + +function baseInteraction(client) { + return { + client, + editReply: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue() + }; +} + +describe('channel subcommand', () => { + function channel(over = {}) { + return { + id: 'c1', + name: 'general', + type: ChannelType.GuildText, + createdAt: new Date('2024-01-01'), + parent: null, + position: 0, + topic: '', + isThread: () => false, ...over + }; + } + + test('renders the base type/id/name fields', async () => { + const interaction = baseInteraction(clientBase()); + interaction.options = {getChannel: () => channel()}; + await info.subcommands.channel(interaction); + const embed = interaction.editReply.mock.calls[0][0].embeds[0]; + const names = embed.fields.map(f => f.name); + expect(names).toEqual(expect.arrayContaining(['Type', 'Id', 'Created', 'Name'])); + }); + + test('adds thread-specific fields for a thread channel', async () => { + const thread = channel({ + isThread: () => true, + ownerId: 'owner1', + autoArchiveDuration: 1440, + messageCount: 5, + memberCount: 3, + archiveTimestamp: 2, + createdTimestamp: 1, + archivedAt: new Date('2024-02-01') + }); + const interaction = baseInteraction(clientBase()); + interaction.options = {getChannel: () => thread}; + await info.subcommands.channel(interaction); + const names = interaction.editReply.mock.calls[0][0].embeds[0].fields.map(f => f.name); + expect(names).toEqual(expect.arrayContaining(['Owner', 'Msgs', 'TMembers', 'AutoArch'])); + }); + + test('lists members for a voice channel', async () => { + const members = new Map([['m1', {user: {id: 'm1'}}], ['m2', {user: {id: 'm2'}}]]); + members.forEach = Map.prototype.forEach.bind(members); + const vc = channel({ + type: ChannelType.GuildVoice, + members + }); + const interaction = baseInteraction(clientBase()); + interaction.options = {getChannel: () => vc}; + await info.subcommands.channel(interaction); + const field = interaction.editReply.mock.calls[0][0].embeds[0].fields.find(f => f.name === 'Members'); + expect(field.value).toContain('<@m1>'); + expect(field.value).toContain('<@m2>'); + }); +}); + +describe('role subcommand', () => { + function role(over = {}) { + return { + id: 'r1', + name: 'Mods', + position: 3, + createdAt: new Date('2024-01-01'), + color: 0, + hexColor: '#000000', + hoist: false, + mentionable: false, + managed: false, + permissions: {toArray: () => ['SEND_MESSAGES', 'KICK_MEMBERS']}, + members: { + size: 0, + forEach: () => { + } + }, + ...over + }; + } + + test('lists explicit permissions when not an administrator', async () => { + const interaction = baseInteraction(clientBase()); + interaction.options = {getRole: () => role()}; + await info.subcommands.role(interaction); + const perms = interaction.editReply.mock.calls[0][0].embeds[0].fields.find(f => f.name === 'Perms'); + expect(perms.value).toContain('SEND_MESSAGES'); + expect(perms.value).toContain('KICK_MEMBERS'); + }); + + test('collapses to ADMINISTRATOR when the role has it', async () => { + const interaction = baseInteraction(clientBase()); + interaction.options = {getRole: () => role({permissions: {toArray: () => ['ADMINISTRATOR', 'SEND_MESSAGES']}})}; + await info.subcommands.role(interaction); + const perms = interaction.editReply.mock.calls[0][0].embeds[0].fields.find(f => f.name === 'Perms'); + expect(perms.value).toBe('```ADMINISTRATOR```'); + }); + + test('lists members when the role has 10 or fewer', async () => { + const members = { + size: 2, + forEach: (fn) => { + fn({id: 'a'}); + fn({id: 'b'}); + } + }; + const interaction = baseInteraction(clientBase()); + interaction.options = {getRole: () => role({members})}; + await info.subcommands.role(interaction); + const who = interaction.editReply.mock.calls[0][0].embeds[0].fields.find(f => f.name === 'Who'); + expect(who.value).toContain('<@a>'); + expect(who.value).toContain('<@b>'); + }); + + test('feature flags surface in the description', async () => { + const interaction = baseInteraction(clientBase()); + interaction.options = { + getRole: () => role({ + hoist: true, + mentionable: true, + managed: true + }) + }; + await info.subcommands.role(interaction); + const desc = interaction.editReply.mock.calls[0][0].embeds[0].data.description; + expect(desc).toContain('hoisted'); + expect(desc).toContain('mentionable'); + expect(desc).toContain('managed'); + }); +}); + +describe('user subcommand enrichment', () => { + function member(over = {}) { + return { + user: { + id: 'u1', + username: 'Alice', + createdAt: new Date('2023-01-01'), + avatarURL: () => 'a', + presence: null + }, + joinedAt: new Date('2024-01-01'), + nickname: null, + premiumSince: null, + displayColor: 0, + displayHexColor: '#000000', + voice: {channel: null}, + roles: { + highest: {id: 'rh'}, + hoist: null + }, + permissions: {toArray: () => ['SEND_MESSAGES']}, + ...over + }; + } + + test('adds level fields when the levels module is enabled', async () => { + const client = clientBase({levels: {enabled: true}}); + client.models = { + levels: { + User: { + findOne: jest.fn().mockResolvedValue({ + level: 5, + xp: 4000, + messages: 100 + }) + } + } + }; + const interaction = baseInteraction(client); + interaction.options = {getMember: () => member()}; + interaction.member = member(); + await info.subcommands.user(interaction); + const names = interaction.editReply.mock.calls[0][0].embeds[0].fields.map(f => f.name); + expect(names).toEqual(expect.arrayContaining(['XP', 'Level', 'Msgs'])); + }); + + test('uses the ADMINISTRATOR shorthand in the permission field', async () => { + const client = clientBase(); + const interaction = baseInteraction(client); + const m = member({permissions: {toArray: () => ['ADMINISTRATOR', 'SEND_MESSAGES']}}); + interaction.options = {getMember: () => m}; + interaction.member = m; + await info.subcommands.user(interaction); + const perms = interaction.editReply.mock.calls[0][0].embeds[0].fields.find(f => f.name === 'Perms'); + expect(perms.value).toContain('ADMINISTRATOR'); + expect(perms.value).not.toContain('SEND_MESSAGES'); + }); +}); \ No newline at end of file diff --git a/tests/levels/botReady.test.js b/tests/levels/botReady.test.js new file mode 100644 index 00000000..25dc318e --- /dev/null +++ b/tests/levels/botReady.test.js @@ -0,0 +1,50 @@ +/* + * Tests for the levels botReady (modules/levels/events/botReady.js) - the + * non-custom-curve paths. When no custom level curve is configured it skips the + * fparser import and, if a leaderboard channel is set, performs a forced + * leaderboard refresh and registers the periodic update interval; otherwise it + * returns without scheduling. updateLeaderBoard and disableModule are mocked. + * (The custom-curve branch uses a dynamic ESM import that Jest's CJS runtime + * can't intercept here, so it is exercised via calculate-level/messageCurve + * tests instead.) + */ +const mockUpdate = jest.fn().mockResolvedValue(); +const mockDisable = jest.fn(); +jest.mock('../../modules/levels/leaderboardChannel', () => ({updateLeaderBoard: (...a) => mockUpdate(...a)})); +jest.mock('../../src/functions/helpers', () => ({disableModule: (...a) => mockDisable(...a)})); + +const handler = require('../../modules/levels/events/botReady'); + +beforeEach(() => { + mockUpdate.mockClear(); + mockDisable.mockClear(); +}); + +function makeClient(config) { + return { + configurations: {levels: {config}}, + intervals: [], + logger: {error: jest.fn()} + }; +} + +test('returns without scheduling when no leaderboard channel is set', async () => { + const client = makeClient({'leaderboard-channel': null}); + await handler.run(client); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(client.intervals).toHaveLength(0); +}); + +test('forces a leaderboard refresh and registers an interval', async () => { + const client = makeClient({'leaderboard-channel': 'lb1'}); + await handler.run(client); + expect(mockUpdate).toHaveBeenCalledWith(client, true); + expect(client.intervals).toHaveLength(1); + clearInterval(client.intervals[0]); +}); + +test('does not disable the module on the plain (no custom curve) path', async () => { + const client = makeClient({'leaderboard-channel': null}); + await handler.run(client); + expect(mockDisable).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/levels/calculateLevel.test.js b/tests/levels/calculateLevel.test.js new file mode 100644 index 00000000..e06b1534 --- /dev/null +++ b/tests/levels/calculateLevel.test.js @@ -0,0 +1,154 @@ +/* + * Behavioural tests for the /calculate-level command + * (modules/levels/commands/calculate-level.js). + * + * Covers the validation branches (out-of-range, above configured max, zero + * xp-range) and the success path where it builds an embed and computes the + * min/avg/max messages and voice-minutes needed to reach a level. + */ + +const command = require('../../modules/levels/commands/calculate-level'); + +function makeInteraction({ + level, + config = {}, + strings = {} + } = {}) { + const moduleConfig = { + curveType: 'EXPONENTIAL', + startFromZero: false, + maximumLevelEnabled: false, + maximumLevel: 100, + 'min-xp': 15, + 'max-xp': 25, + voiceXPPerMinute: 0, + ...config + }; + return { + client: { + configurations: { + levels: { + config: moduleConfig, + strings: {leaderboardEmbed: {color: 'GREEN'}, ...strings} + } + }, + strings: { + footer: 'f', + footerImgUrl: '', + disableFooterTimestamp: true + } + }, + options: {getInteger: () => level}, + reply: jest.fn().mockResolvedValue() + }; +} + +describe('/calculate-level validation', () => { + test('rejects a level below the minimum', async () => { + const interaction = makeInteraction({ + level: 0, + config: {startFromZero: false} + }); + await command.run(interaction); + expect(interaction.reply).toHaveBeenCalledTimes(1); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.ephemeral).toBe(true); + expect(arg.content).toContain('levels.level-out-of-range'); + }); + + test('allows level 0 when startFromZero is enabled', async () => { + const interaction = makeInteraction({ + level: 0, + config: {startFromZero: true} + }); + await command.run(interaction); + const arg = interaction.reply.mock.calls[0][0]; + // not the out-of-range error; should be the success embed + expect(arg.content).toBeUndefined(); + expect(arg.embeds).toHaveLength(1); + }); + + test('rejects a level above the configured maximum', async () => { + const interaction = makeInteraction({ + level: 50, + config: { + maximumLevelEnabled: true, + maximumLevel: 10 + } + }); + await command.run(interaction); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.content).toContain('levels.calculate-level-above-max'); + }); + + test('errors when the xp range is zero', async () => { + const interaction = makeInteraction({ + level: 5, + config: { + 'min-xp': 0, + 'max-xp': 0 + } + }); + await command.run(interaction); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.content).toContain('levels.calculate-level-zero-xp-range'); + }); +}); + +describe('/calculate-level success path', () => { + test('replies with an embed and computes message estimates', async () => { + // EXPONENTIAL level 2 (internal) needs 2000 xp. With xp 15-25: + // maxMessages = ceil(2000/15)=134, minMessages = ceil(2000/25)=80, avg=ceil(2000/20)=100 + const interaction = makeInteraction({ + level: 2, + config: { + 'min-xp': 15, + 'max-xp': 25 + } + }); + await command.run(interaction); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.embeds).toHaveLength(1); + const embed = arg.embeds[0]; + const fields = embed.fields || (embed.data && embed.data.fields); + const messagesField = fields.find(f => f.name.includes('messages-needed')); + expect(messagesField.value).toContain('min=80'); + expect(messagesField.value).toContain('avg=100'); + expect(messagesField.value).toContain('max=134'); + }); + + test('level 1 needs zero xp', async () => { + const interaction = makeInteraction({level: 1}); + await command.run(interaction); + const arg = interaction.reply.mock.calls[0][0]; + const embed = arg.embeds[0]; + const fields = embed.fields || (embed.data && embed.data.fields); + const xpField = fields.find(f => f.name.includes('xp-needed')); + expect(xpField.value).toBe('0'); + }); + + test('adds a voice-minutes field when voiceXPPerMinute > 0', async () => { + const interaction = makeInteraction({ + level: 2, + config: {voiceXPPerMinute: '10'} + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + const fields = embed.fields || (embed.data && embed.data.fields); + // 2000 xp / 10 per minute = 200 minutes + const voiceField = fields.find(f => f.name.includes('voice-needed')); + expect(voiceField).toBeDefined(); + expect(voiceField.value).toContain('minutes=200'); + }); + + test('omits the voice field when voiceXPPerMinute is 0', async () => { + const interaction = makeInteraction({ + level: 2, + config: {voiceXPPerMinute: 0} + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + const fields = embed.fields || (embed.data && embed.data.fields); + expect(fields.find(f => f.name.includes('voice-needed'))).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/tests/levels/calculateLevelEdges.test.js b/tests/levels/calculateLevelEdges.test.js new file mode 100644 index 00000000..ba7419f6 --- /dev/null +++ b/tests/levels/calculateLevelEdges.test.js @@ -0,0 +1,152 @@ +/* + * Additional edge cases for /calculate-level (modules/levels/commands/ + * calculate-level.js) not covered by calculateLevel.test.js: + * - the invalid-custom-formula branch when calculateLevelXP throws, + * - getFormulaString selection rendered into the embed for LINEAR / + * EXPONENTIATION / CUSTOM (with and without a custom curve string), + * - the upper bound (> 1,000,000) rejection. + * calculateLevelXP is mocked per-test so we can force the throw without a real + * math parser. + */ +jest.mock('discord.js', () => { + class MessageEmbed { + constructor() { + this.fields = []; + this.data = {}; + } + + setColor(c) { + this.data.color = c; + return this; + } + + setTitle(t) { + this.data.title = t; + return this; + } + + setFooter(f) { + this.data.footer = f; + return this; + } + + setTimestamp() { + this.data.timestamp = true; + return this; + } + + addField(name, value, inline) { + this.fields.push({ + name, + value, + inline + }); + return this; + } + } + + return {MessageEmbed}; +}); +const mockCalc = jest.fn(); +jest.mock('../../modules/levels/events/messageCreate', () => ({ + calculateLevelXP: (...a) => mockCalc(...a) +})); + +const command = require('../../modules/levels/commands/calculate-level'); + +beforeEach(() => mockCalc.mockReset().mockReturnValue(3000)); + +function makeInteraction({ + level, + config = {} + } = {}) { + return { + client: { + configurations: { + levels: { + config: { + curveType: 'EXPONENTIAL', + startFromZero: false, + maximumLevelEnabled: false, + maximumLevel: 100, + 'min-xp': 15, + 'max-xp': 25, + voiceXPPerMinute: 0, ...config + }, + strings: {leaderboardEmbed: {color: 'GREEN'}} + } + }, + strings: { + footer: 'f', + disableFooterTimestamp: true + } + }, + options: {getInteger: () => level}, + reply: jest.fn().mockResolvedValue() + }; +} + +test('rejects a level above the 1,000,000 hard ceiling', async () => { + const interaction = makeInteraction({level: 1000001}); + await command.run(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('level-out-of-range'); +}); + +test('reports invalid-custom-formula when the curve evaluator throws', async () => { + mockCalc.mockImplementation(() => { + throw new Error('bad formula'); + }); + const interaction = makeInteraction({ + level: 5, + config: {curveType: 'CUSTOM'} + }); + await command.run(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('invalid-custom-formula'); +}); + +test('renders the LINEAR formula string in the embed', async () => { + const interaction = makeInteraction({ + level: 5, + config: {curveType: 'LINEAR'} + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + const formula = embed.fields.find(f => f.value.includes('750')); + expect(formula.value).toBe('`x * 750`'); +}); + +test('renders the EXPONENTIATION formula string', async () => { + const interaction = makeInteraction({ + level: 5, + config: {curveType: 'EXPONENTIATION'} + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + expect(embed.fields.some(f => f.value.includes('350 * (x - 1) ^ 2'))).toBe(true); +}); + +test('renders the supplied custom curve string for CUSTOM', async () => { + const interaction = makeInteraction({ + level: 5, + config: { + curveType: 'CUSTOM', + customLevelCurve: 'x^3' + } + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + expect(embed.fields.some(f => f.value === '`x^3`')).toBe(true); +}); + +test('falls back to the EXPONENTIAL formula when CUSTOM has no curve string', async () => { + const interaction = makeInteraction({ + level: 5, + config: { + curveType: 'CUSTOM', + customLevelCurve: null + } + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + expect(embed.fields.some(f => f.value.includes('x * 750 + ((x - 1) * 500)'))).toBe(true); +}); \ No newline at end of file diff --git a/tests/levels/grantXPAndLevelUP.test.js b/tests/levels/grantXPAndLevelUP.test.js new file mode 100644 index 00000000..5ccc82e7 --- /dev/null +++ b/tests/levels/grantXPAndLevelUP.test.js @@ -0,0 +1,329 @@ +/* + * Tests for grantXPAndLevelUP (modules/levels/events/messageCreate.js), the core + * XP-grant + level-up engine. Covers: + * - blacklisted-role short circuit. + * - lazy user creation, message-count increment for the 'message' type. + * - daily counter reset when the stored date is stale, and the voice + * accumulation path. + * - role-factor and channel-multiplier XP scaling. + * - the level-up path (single and multi-level jumps), reward-role granting, + * onlyTopLevelRole removal, and the corrupted-values safety abort. + * - levelUpMessagesConditions gating of the announcement. + * Curve config is LINEAR (xp = level*750) so thresholds are deterministic. + */ +const mainStub = require('../__stubs__/main'); + +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn((i) => i), + randomIntFromInterval: jest.fn(() => 1), + randomElementFromArray: jest.fn((arr) => arr[0]), + embedTypeV2: jest.fn(async (m) => ({_msg: m})), + formatDiscordUserName: (u) => u.username, + formatNumber: (n) => String(n), + todayInServerTZ: () => '2026-06-02', + formatVoiceDuration: (s) => `${s}s` +})); +jest.mock('discord.js', () => ({ChannelType: {GuildText: 0}})); + +const {grantXPAndLevelUP} = require('../../modules/levels/events/messageCreate'); + +function config(overrides = {}) { + return { + curveType: 'LINEAR', + startFromZero: false, + maximumLevelEnabled: false, + blacklistedRoles: [], + multiplication_roles: {}, + multiplication_channels: {}, + reward_roles: {}, + onlyTopLevelRole: false, + level_up_channel_id: null, + levelUpMessagesConditions: 'all', + randomMessages: false, + ...overrides + }; +} + +function makeClient({ + cfg = {}, + user, + channels = [] + } = {}) { + const conf = { + levels: { + config: config(cfg), + strings: { + level_up_message: 'LVLUP', + level_up_message_with_reward: 'LVLUP_REWARD' + }, + 'special-levelup-messages': [], + 'random-levelup-messages': [] + } + }; + // grantXPAndLevelUP closes over the module-level main client for the CUSTOM + // curve; mirror config there too so any lookups resolve. + mainStub.client.configurations = conf; + const channelCache = {find: (fn) => channels.find(fn)}; + return { + configurations: conf, + logger: {error: jest.fn()}, + channels: {cache: channelCache}, + models: { + levels: { + User: { + findOne: jest.fn().mockResolvedValue(user), + create: jest.fn(async (vals) => ({ + level: 1, + dailyMessages: 0, + dailyVoiceSeconds: 0, + dailyResetDate: null, ...vals, + save: jest.fn().mockResolvedValue() + })) + } + } + } + }; +} + +function makeMember({roleIds = []} = {}) { + const cache = new Map(roleIds.map(id => [id, {id}])); + cache.some = (fn) => [...cache.values()].some(fn); + cache.has = (id) => [...cache.keys()].includes(id); + cache.filter = (fn) => { + const arr = [...cache.values()].filter(fn); + return {values: () => arr[Symbol.iterator]()}; + }; + return { + // getMemberRoleFactor reads member.client.configurations; default to the + // shared main stub so members work even when a test doesn't relink it. + client: mainStub.client, + user: { + id: 'u1', + username: 'U', + avatarURL: () => 'a', + defaultAvatarURL: 'd' + }, + roles: { + cache, + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + } + }; +} + +function userRow(over = {}) { + return { + userID: 'u1', + xp: 0, + level: 1, + messages: 0, + dailyMessages: 0, + dailyVoiceSeconds: 0, + dailyResetDate: '2026-06-02', + save: jest.fn().mockResolvedValue(), ...over + }; +} + +test('short-circuits for a member holding a blacklisted role', async () => { + const client = makeClient({cfg: {blacklistedRoles: ['bad']}}); + const member = makeMember({roleIds: ['bad']}); + await grantXPAndLevelUP(client, member, 100, 'message', {id: 'c'}); + expect(client.models.levels.User.findOne).not.toHaveBeenCalled(); +}); + +test('creates the user row lazily and increments the message count', async () => { + const client = makeClient({user: null}); + const member = makeMember(); + const channel = { + id: 'c', + send: jest.fn().mockResolvedValue() + }; + await grantXPAndLevelUP(client, member, 10, 'message', channel); + expect(client.models.levels.User.create).toHaveBeenCalled(); +}); + +test('adds plain xp without leveling up when below the next threshold', async () => { + const user = userRow({ + xp: 0, + level: 1 + }); + const client = makeClient({user}); + const member = makeMember(); + const channel = { + id: 'c', + send: jest.fn().mockResolvedValue() + }; + await grantXPAndLevelUP(client, member, 100, 'message', channel); // 100 < 1500 (level 2) + expect(user.xp).toBe(100); + expect(user.messages).toBe(1); + expect(channel.send).not.toHaveBeenCalled(); +}); + +test('resets the daily counters when the stored reset date is stale', async () => { + const user = userRow({ + dailyResetDate: '2020-01-01', + dailyMessages: 99, + dailyVoiceSeconds: 999 + }); + const client = makeClient({user}); + const member = makeMember(); + await grantXPAndLevelUP(client, member, 10, 'message', { + id: 'c', + send: jest.fn() + }); + expect(user.dailyResetDate).toBe('2026-06-02'); + expect(user.dailyMessages).toBe(1); // reset to 0 then +1 for this message +}); + +test('accumulates daily voice seconds for the voice type', async () => { + const user = userRow(); + const client = makeClient({user}); + const member = makeMember(); + await grantXPAndLevelUP(client, member, 10, 'voice', { + id: 'c', + send: jest.fn() + }, null, 90); + expect(user.dailyVoiceSeconds).toBe(90); + expect(user.messages).toBe(0); // voice does not bump message count +}); + +test('scales xp by role factor and channel multiplier', async () => { + const user = userRow(); + const client = makeClient({ + user, + cfg: { + multiplication_roles: {boost: '2'}, + multiplication_channels: {c: '3'} + } + }); + const member = makeMember({roleIds: ['boost']}); + member.client = client; + await grantXPAndLevelUP(client, member, 10, 'message', { + id: 'c', + send: jest.fn() + }); + expect(user.xp).toBe(60); // 10 * 2 (role) * 3 (channel) +}); + +test('levels up a single level and announces in the channel', async () => { + const user = userRow({ + xp: 0, + level: 1 + }); + const client = makeClient({user}); + const member = makeMember(); + const channel = { + id: 'c', + send: jest.fn().mockResolvedValue() + }; + await grantXPAndLevelUP(client, member, 1500, 'message', channel); // reaches level 2 + expect(user.level).toBe(2); + expect(channel.send).toHaveBeenCalled(); +}); + +test('jumps multiple levels at once when xp overshoots', async () => { + const user = userRow({ + xp: 0, + level: 1 + }); + const client = makeClient({user}); + const member = makeMember(); + await grantXPAndLevelUP(client, member, 3000, 'message', { + id: 'c', + send: jest.fn().mockResolvedValue() + }); + expect(user.level).toBe(4); // 3000 -> level 4 (4*750) +}); + +test('grants the reward role for the reached level', async () => { + const user = userRow({ + xp: 0, + level: 1 + }); + const client = makeClient({ + user, + cfg: {reward_roles: {'2': 'roleTwo'}} + }); + const member = makeMember(); + await grantXPAndLevelUP(client, member, 1500, 'message', { + id: 'c', + send: jest.fn().mockResolvedValue() + }); + expect(member.roles.add).toHaveBeenCalledWith('roleTwo', expect.any(String)); +}); + +test('onlyTopLevelRole removes previously held reward roles before adding', async () => { + const user = userRow({ + xp: 0, + level: 1 + }); + const client = makeClient({ + user, + cfg: { + onlyTopLevelRole: true, + reward_roles: {'2': 'roleTwo'} + } + }); + const member = makeMember({roleIds: ['roleTwo']}); + await grantXPAndLevelUP(client, member, 1500, 'message', { + id: 'c', + send: jest.fn().mockResolvedValue() + }); + expect(member.roles.remove).toHaveBeenCalledWith('roleTwo', expect.any(String)); +}); + +test('aborts the level-up loop for corrupted stored values', async () => { + const user = userRow({ + xp: Infinity, + level: 1 + }); + const client = makeClient({user}); + const member = makeMember(); + const channel = { + id: 'c', + send: jest.fn() + }; + await grantXPAndLevelUP(client, member, 1500, 'message', channel); + expect(client.logger.error).toHaveBeenCalledWith(expect.stringContaining('corrupted values')); + expect(channel.send).not.toHaveBeenCalled(); +}); + +test('suppresses the announcement when levelUpMessagesConditions is none', async () => { + const user = userRow({ + xp: 0, + level: 1 + }); + const client = makeClient({ + user, + cfg: {levelUpMessagesConditions: 'none'} + }); + const member = makeMember(); + const channel = { + id: 'c', + send: jest.fn() + }; + await grantXPAndLevelUP(client, member, 1500, 'message', channel); + expect(user.level).toBe(2); // still levels up + expect(channel.send).not.toHaveBeenCalled(); // but no message +}); + +test('only-role-rewards condition suppresses non-reward level-ups', async () => { + const user = userRow({ + xp: 0, + level: 1 + }); + const client = makeClient({ + user, + cfg: { + levelUpMessagesConditions: 'only-role-rewards', + reward_roles: {} + } + }); + const member = makeMember(); + const channel = { + id: 'c', + send: jest.fn() + }; + await grantXPAndLevelUP(client, member, 1500, 'message', channel); + expect(channel.send).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/levels/guildMemberRemove.test.js b/tests/levels/guildMemberRemove.test.js new file mode 100644 index 00000000..c004107f --- /dev/null +++ b/tests/levels/guildMemberRemove.test.js @@ -0,0 +1,46 @@ +/* + * Tests for the levels guildMemberRemove handler. With reset-on-leave enabled it + * deletes the leaver's XP row and refreshes the live leaderboard; with it + * disabled it is a no-op, and it tolerates the leaver having no stored row. + * The leaderboardChannel.updateLeaderBoard sink is mocked. + */ +const mockUpdate = jest.fn().mockResolvedValue(); +jest.mock('../../modules/levels/leaderboardChannel', () => ({updateLeaderBoard: (...a) => mockUpdate(...a)})); + +const handler = require('../../modules/levels/events/guildMemberRemove'); + +beforeEach(() => mockUpdate.mockClear()); + +function makeClient({ + resetOnLeave = true, + user + } = {}) { + return { + configurations: {levels: {config: {'reset-on-leave': resetOnLeave}}}, + models: {levels: {User: {findOne: jest.fn().mockResolvedValue(user)}}} + }; +} + +const member = {user: {id: 'gone'}}; + +test('does nothing when reset-on-leave is disabled', async () => { + const client = makeClient({resetOnLeave: false}); + await handler.run(client, member); + expect(client.models.levels.User.findOne).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); +}); + +test('returns quietly when the leaver has no stored row', async () => { + const client = makeClient({user: null}); + await handler.run(client, member); + expect(mockUpdate).not.toHaveBeenCalled(); +}); + +test('destroys the leaver row and refreshes the leaderboard', async () => { + const row = {destroy: jest.fn().mockResolvedValue()}; + const client = makeClient({user: row}); + await handler.run(client, member); + expect(client.models.levels.User.findOne).toHaveBeenCalledWith({where: {userID: 'gone'}}); + expect(row.destroy).toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalledWith(client); +}); \ No newline at end of file diff --git a/tests/levels/leaderboardChannel.test.js b/tests/levels/leaderboardChannel.test.js new file mode 100644 index 00000000..bbd46b99 --- /dev/null +++ b/tests/levels/leaderboardChannel.test.js @@ -0,0 +1,235 @@ +/* + * Tests for the levels live-leaderboard channel updater + * (modules/levels/leaderboardChannel.js). Covers: + * - the no-channel-configured and unchanged (non-force) early returns, + * - the missing/non-text channel error, + * - building + sending a fresh leaderboard message (persisting its id), + * - editing an existing one, + * - the empty-board placeholder, + * - registerNeededEdit flipping the changed flag so a non-force update runs. + * discord.js + helpers are mocked; LINEAR curve keeps xp numbers simple. + */ +const mainStub = require('../__stubs__/main'); + +jest.mock('../../src/functions/helpers', () => ({ + formatDiscordUserName: (u) => u.username, + formatNumber: (n) => String(n), + parseEmbedColor: (c) => c, + safeSetFooter: jest.fn() +})); +jest.mock('discord.js', () => { + const ChannelType = {GuildText: 0}; + + class MessageEmbed { + constructor() { + this.fields = []; + this.data = {}; + } + + setTitle(t) { + this.data.title = t; + return this; + } + + setDescription(d) { + this.data.description = d; + return this; + } + + setColor(c) { + this.data.color = c; + return this; + } + + setThumbnail(t) { + this.data.thumbnail = t; + return this; + } + + setTimestamp() { + return this; + } + + addField(name, value, inline) { + this.fields.push({ + name, + value, + inline + }); + return this; + } + } + + return { + ChannelType, + MessageEmbed + }; +}); + +const {ChannelType} = require('discord.js'); +const lb = require('../../modules/levels/leaderboardChannel'); + +const strings = { + liveLeaderBoardEmbed: { + title: 'T', + description: 'D', + color: 'GREEN', + button: 'Show' + } +}; + +function makeClient({ + leaderboardChannel = 'lb1', + channel, + users = [], + row + } = {}) { + const conf = { + levels: { + config: { + curveType: 'LINEAR', + maximumLevelEnabled: false, + startFromZero: false, + 'leaderboard-channel': leaderboardChannel, + 'leaderboard-channel-max-amount': 60, + useTags: true + }, + strings + } + }; + mainStub.client.configurations = conf; + return { + configurations: conf, + strings: {disableFooterTimestamp: true}, + logger: { + error: jest.fn(), + info: jest.fn() + }, + channels: {fetch: jest.fn().mockResolvedValue(channel)}, + models: { + levels: { + LiveLeaderboard: { + findOrCreate: jest.fn().mockResolvedValue([row || { + messageID: null, + save: jest.fn().mockResolvedValue() + }]) + }, + User: {findAll: jest.fn().mockResolvedValue(users)} + } + } + }; +} + +function makeChannel(memberIds = [], {existing} = {}) { + const cache = new Map(memberIds.map(id => [id, { + user: { + username: `n-${id}`, + toString: () => `<@${id}>` + } + }])); + return { + id: 'lb1', + type: ChannelType.GuildText, + guild: { + members: {cache}, + iconURL: () => 'icon' + }, + messages: {fetch: jest.fn().mockResolvedValue(existing || null)}, + send: jest.fn().mockResolvedValue({ + id: 'sent1', + url: 'u' + }) + }; +} + +test('returns immediately when no leaderboard channel is configured', async () => { + const client = makeClient({leaderboardChannel: null}); + await lb.updateLeaderBoard(client, true); + expect(client.channels.fetch).not.toHaveBeenCalled(); +}); + +test('non-force update is skipped until a change is registered', async () => { + const channel = makeChannel(); + const client = makeClient({channel}); + await lb.updateLeaderBoard(client, false); + expect(client.channels.fetch).not.toHaveBeenCalled(); + + // registerNeededEdit flips the module-level "changed" flag. + lb.registerNeededEdit(); + await lb.updateLeaderBoard(client, false); + expect(client.channels.fetch).toHaveBeenCalled(); +}); + +test('errors when the configured channel is missing or not text based', async () => { + const client = makeClient({channel: null}); + await lb.updateLeaderBoard(client, true); + expect(client.logger.error).toHaveBeenCalledWith(expect.stringContaining('leaderboard-channel-not-found')); +}); + +test('sends a fresh leaderboard and persists the message id', async () => { + const channel = makeChannel(['a', 'b']); + const row = { + messageID: null, + save: jest.fn().mockResolvedValue() + }; + const client = makeClient({ + channel, + row, + users: [{ + userID: 'a', + level: 3, + xp: 3000 + }, { + userID: 'b', + level: 2, + xp: 2000 + }] + }); + await lb.updateLeaderBoard(client, true); + expect(channel.send).toHaveBeenCalled(); + expect(row.messageID).toBe('sent1'); + expect(row.save).toHaveBeenCalled(); + const field = channel.send.mock.calls[0][0].embeds[0].fields[0]; + expect(field.value).toContain('p=1'); + expect(field.value).toContain('p=2'); +}); + +test('edits an existing leaderboard message', async () => { + const existing = { + id: 'm1', + url: 'http://m', + edit: jest.fn().mockResolvedValue() + }; + const channel = makeChannel(['a'], {existing}); + const row = { + messageID: 'm1', + save: jest.fn() + }; + const client = makeClient({ + channel, + row, + users: [{ + userID: 'a', + level: 1, + xp: 100 + }] + }); + await lb.updateLeaderBoard(client, true); + expect(existing.edit).toHaveBeenCalled(); + expect(channel.send).not.toHaveBeenCalled(); +}); + +test('shows the empty placeholder when no cached users qualify', async () => { + const channel = makeChannel([]); // no members cached + const client = makeClient({ + channel, + users: [{ + userID: 'ghost', + level: 5, + xp: 5000 + }] + }); + await lb.updateLeaderBoard(client, true); + const field = channel.send.mock.calls[0][0].embeds[0].fields[0]; + expect(field.value).toContain('no-user-on-leaderboard'); +}); \ No newline at end of file diff --git a/tests/levels/leaderboardCommand.test.js b/tests/levels/leaderboardCommand.test.js new file mode 100644 index 00000000..9275c93c --- /dev/null +++ b/tests/levels/leaderboardCommand.test.js @@ -0,0 +1,227 @@ +/* + * Tests for the /leaderboard command (modules/levels/commands/leaderboard.js). + * Covers the empty-board early reply, the default xp-sorted listing (one entry + * per cached member, skipping members no longer in the guild), the levels-sorted + * grouping (one field per level), the "your level" footer field when the caller + * is on the board, and the config.options() builder defaulting note. The + * paginator (sendMultipleSiteButtonMessage) is mocked to capture the built + * pages; the main client stub supplies the curve config. + */ +const mainStub = require('../__stubs__/main'); + +const mockSend = jest.fn(); +jest.mock('../../src/functions/helpers', () => ({ + sendMultipleSiteButtonMessage: (...a) => mockSend(...a), + truncate: (s) => s, + formatNumber: (n) => String(n), + formatDiscordUserName: (u) => u.username, + parseEmbedColor: (c) => c, + safeSetFooter: jest.fn() +})); +jest.mock('discord.js', () => { + class MessageEmbed { + constructor() { + this.fields = []; + this.data = {}; + } + + setColor(c) { + this.data.color = c; + return this; + } + + setThumbnail(t) { + this.data.thumbnail = t; + return this; + } + + setTitle(t) { + this.data.title = t; + return this; + } + + setDescription(d) { + this.data.description = d; + return this; + } + + addField(name, value, inline) { + this.fields.push({ + name, + value, + inline + }); + return this; + } + + addFields(fields) { + this.fields.push(...fields); + return this; + } + } + + return {MessageEmbed}; +}); + +const command = require('../../modules/levels/commands/leaderboard'); + +const levelsConfig = { + curveType: 'LINEAR', + maximumLevelEnabled: false, + startFromZero: false, + sortLeaderboardBy: 'xp', + useTags: true +}; + +beforeEach(() => { + mockSend.mockClear(); + // The command reads the shared main-stub client for curve/displayLevel. + mainStub.client.configurations = { + levels: { + config: levelsConfig, + strings: {} + } + }; +}); + +function makeInteraction({ + users = [], + sortBy = null, + cachedIds, + callerId = 'caller' + } = {}) { + const present = cachedIds || users.map(u => u.userID); + const memberCache = new Map(present.map(id => [id, { + user: { + username: `name-${id}`, + toString: () => `<@${id}>` + } + }])); + return { + user: {id: callerId}, + channel: {}, + options: {getString: () => sortBy}, + guild: { + iconURL: () => 'icon', + members: {cache: memberCache} + }, + client: { + configurations: { + levels: { + config: levelsConfig, + strings: { + leaderboardEmbed: { + color: 'GREEN', + title: 'LB', + description: 'desc', + your_level: 'You', + you_are_level_x_with_x_xp: 'L%level% X%xp%' + } + } + } + }, + models: {levels: {User: {findAll: jest.fn().mockResolvedValue(users)}}} + }, + reply: jest.fn().mockResolvedValue() + }; +} + +test('replies with the empty-board message when there are no users', async () => { + const interaction = makeInteraction({users: []}); + await command.run(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('no-user-on-leaderboard'); + expect(mockSend).not.toHaveBeenCalled(); +}); + +test('xp sort lists one notation per cached member', async () => { + const users = [ + { + userID: 'a', + level: 3, + xp: 3000 + }, + { + userID: 'b', + level: 2, + xp: 2000 + } + ]; + const interaction = makeInteraction({users}); + await command.run(interaction); + const pages = mockSend.mock.calls[0][1]; + const value = pages[0].fields.find(f => f.name === 'levels.users').value; + expect(value).toContain('p=1'); + expect(value).toContain('p=2'); +}); + +test('xp sort skips users no longer cached in the guild', async () => { + const users = [ + { + userID: 'a', + level: 3, + xp: 3000 + }, + { + userID: 'gone', + level: 9, + xp: 9000 + } + ]; + const interaction = makeInteraction({ + users, + cachedIds: ['a', 'caller'] + }); + await command.run(interaction); + const value = mockSend.mock.calls[0][1][0].fields.find(f => f.name === 'levels.users').value; + expect(value).toContain('u=name-a'); + expect(value).not.toContain('gone'); +}); + +test('levels sort groups members into one field per level', async () => { + const users = [ + { + userID: 'a', + level: 5, + xp: 5000 + }, + { + userID: 'b', + level: 5, + xp: 4900 + }, + { + userID: 'c', + level: 2, + xp: 2000 + } + ]; + const interaction = makeInteraction({ + users, + sortBy: 'levels' + }); + await command.run(interaction); + const page = mockSend.mock.calls[0][1][0]; + const levelFields = page.fields.filter(f => typeof f.name === 'string' && f.name.includes('levels.level')); + expect(levelFields.length).toBe(2); +}); + +test('adds the "your level" field when the caller is on the board', async () => { + const users = [{ + userID: 'caller', + level: 4, + xp: 4000 + }]; + const interaction = makeInteraction({ + users, + callerId: 'caller' + }); + await command.run(interaction); + const page = mockSend.mock.calls[0][1][0]; + expect(page.fields.some(f => f.name === 'You')).toBe(true); +}); + +test('config.options() exposes the sort-by choice defaulting to the configured sort', () => { + const opts = command.config.options({configurations: {levels: {config: {sortLeaderboardBy: 'levels'}}}}); + expect(opts[0].name).toBe('sort-by'); + expect(opts[0].choices.map(c => c.value)).toEqual(['levels', 'xp']); +}); \ No newline at end of file diff --git a/tests/levels/levelCurves.test.js b/tests/levels/levelCurves.test.js new file mode 100644 index 00000000..da2610ab --- /dev/null +++ b/tests/levels/levelCurves.test.js @@ -0,0 +1,158 @@ +/* + * Pure-logic tests for the levels XP curve helpers exported from + * modules/levels/events/messageCreate.js: + * - calculateLevelXP: the three built-in level->XP formulas (EXPONENTIAL, + * LINEAR, EXPONENTIATION) plus the CUSTOM-formula fallback. + * - isMaxLevel: respects maximumLevelEnabled and the startFromZero offset. + * - displayLevel: subtracts the startFromZero offset and clamps to the cap. + * - getMemberRoleFactor: multiplies the configured per-role factors together. + */ + +const { + calculateLevelXP, + isMaxLevel, + displayLevel, + getMemberRoleFactor +} = require('../../modules/levels/events/messageCreate'); + +function makeClient(config = {}) { + return { + configurations: { + levels: { + config: { + curveType: 'EXPONENTIAL', + startFromZero: false, + maximumLevelEnabled: false, + maximumLevel: 100, + multiplication_roles: {}, + ...config + } + } + } + }; +} + +describe('calculateLevelXP - built-in curves', () => { + test('EXPONENTIAL: x*750 + (x-1)*500', () => { + const client = makeClient({curveType: 'EXPONENTIAL'}); + expect(calculateLevelXP(client, 1)).toBe(750); // 750 + 0 + expect(calculateLevelXP(client, 2)).toBe(2000); // 1500 + 500 + expect(calculateLevelXP(client, 10)).toBe(12000); // 7500 + 4500 + }); + + test('LINEAR: x*750', () => { + const client = makeClient({curveType: 'LINEAR'}); + expect(calculateLevelXP(client, 1)).toBe(750); + expect(calculateLevelXP(client, 4)).toBe(3000); + }); + + test('EXPONENTIATION: 350*(x-1)^2', () => { + const client = makeClient({curveType: 'EXPONENTIATION'}); + expect(calculateLevelXP(client, 1)).toBe(0); // 350*0 + expect(calculateLevelXP(client, 3)).toBe(1400); // 350*4 + expect(calculateLevelXP(client, 11)).toBe(35000); // 350*100 + }); + + test('curve is monotonically increasing (required by the level-up loop)', () => { + const client = makeClient({curveType: 'EXPONENTIAL'}); + let last = -Infinity; + for (let level = 1; level <= 50; level++) { + const required = calculateLevelXP(client, level); + expect(required).toBeGreaterThan(last); + last = required; + } + }); +}); + +describe('isMaxLevel', () => { + test('returns false when maximum level is disabled', () => { + const client = makeClient({ + maximumLevelEnabled: false, + maximumLevel: 10 + }); + expect(isMaxLevel(999, client)).toBe(false); + }); + + test('true once the level reaches the cap (startFromZero=false)', () => { + const client = makeClient({ + maximumLevelEnabled: true, + maximumLevel: 10, + startFromZero: false + }); + expect(isMaxLevel(9, client)).toBe(false); + expect(isMaxLevel(10, client)).toBe(true); + expect(isMaxLevel(11, client)).toBe(true); + }); + + test('startFromZero shifts the internal level by one', () => { + const client = makeClient({ + maximumLevelEnabled: true, + maximumLevel: 10, + startFromZero: true + }); + // internal level 10 -> displayed 9, not yet capped + expect(isMaxLevel(10, client)).toBe(false); + // internal level 11 -> displayed 10, capped + expect(isMaxLevel(11, client)).toBe(true); + }); +}); + +describe('displayLevel', () => { + test('returns the level unchanged when startFromZero is false', () => { + const client = makeClient({startFromZero: false}); + expect(displayLevel(5, client)).toBe('5'); + }); + + test('subtracts one when startFromZero is true', () => { + const client = makeClient({startFromZero: true}); + expect(displayLevel(5, client)).toBe('4'); + }); + + test('clamps to the maximum level once capped', () => { + const client = makeClient({ + maximumLevelEnabled: true, + maximumLevel: 10, + startFromZero: false + }); + expect(displayLevel(50, client)).toBe('10'); + }); +}); + +describe('getMemberRoleFactor', () => { + function makeMember(client, roleIds) { + const roles = roleIds.map(id => ({id})); + return { + client, + roles: { + cache: { + filter(fn) { + return {values: () => roles.filter(fn)}; + } + } + } + }; + } + + test('returns 1 when the member has no multiplier roles', () => { + const client = makeClient({multiplication_roles: {r1: '2'}}); + const member = makeMember(client, ['other']); + expect(getMemberRoleFactor(member)).toBe(1); + }); + + test('returns the single configured factor', () => { + const client = makeClient({multiplication_roles: {r1: '2.5'}}); + const member = makeMember(client, ['r1']); + expect(getMemberRoleFactor(member)).toBe(2.5); + }); + + test('multiplies multiple role factors together', () => { + const client = makeClient({ + multiplication_roles: { + r1: '2', + r2: '3' + } + }); + const member = makeMember(client, ['r1', 'r2', 'noise']); + expect(getMemberRoleFactor(member)).toBe(6); + }); +}); \ No newline at end of file diff --git a/tests/levels/manageLevels.test.js b/tests/levels/manageLevels.test.js new file mode 100644 index 00000000..285da951 --- /dev/null +++ b/tests/levels/manageLevels.test.js @@ -0,0 +1,325 @@ +/* + * Tests for the /manage-levels subcommands (modules/levels/commands/ + * manage-levels.js). Covers: + * - reset-xp: the confirm guard, user reset (and user-not-found), full server + * reset, and log-channel notification. + * - edit-xp set/add/remove via runXPAction: creates a missing user, the + * negative-xp and out-of-range guards, the level-up loop, and the success + * reply / logging. + * - edit-level set/add via runLevelAction: no-profile guard, negative-level + * guard, role reconciliation through fixLevelRoles (reward + onlyTopLevel + * removal), and startFromZero offset. + * Uses the LINEAR curve (xp = level*750) so level thresholds are predictable. + */ +jest.mock('../../src/functions/helpers', () => ({ + formatDiscordUserName: (u) => u.username, + formatNumber: (n) => String(n) +})); +jest.mock('../../modules/levels/leaderboardChannel', () => ({registerNeededEdit: jest.fn()})); + +const command = require('../../modules/levels/commands/manage-levels'); + +function baseConfig(overrides = {}) { + return { + curveType: 'LINEAR', + startFromZero: false, + reward_roles: {}, + onlyTopLevelRole: false, + ...overrides + }; +} + +function makeMember(roleIds = []) { + const cache = new Map(roleIds.map(id => [id, {id}])); + cache.has = (id) => [...cache.keys()].includes(id); + return { + user: { + id: 'target', + username: 'Target', + toString: () => '<@target>' + }, + roles: { + cache, + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + } + }; +} + +function makeInteraction({ + options = {}, + member, + user = null, + allUsers, + config = {}, + logChannel + } = {}) { + const User = { + findOne: jest.fn().mockResolvedValue(user), + create: jest.fn(async (vals) => ({ + ...vals, + level: 1, + save: jest.fn() + })), + findAll: jest.fn().mockResolvedValue(allUsers || []) + }; + return { + user: { + id: 'admin', + username: 'Admin' + }, + options: { + getUser: (k) => options[`user:${k}`] ?? options.user ?? null, + getMember: () => member, + getBoolean: (k) => options[`bool:${k}`] ?? null, + getNumber: () => options.value + }, + client: { + configurations: {levels: {config: baseConfig(config)}}, + models: {levels: {User}}, + logger: {info: jest.fn()}, + logChannel + }, + reply: jest.fn().mockResolvedValue(), + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue() + }; +} + +describe('reset-xp', () => { + test('asks for confirmation when confirm is not set (server scope)', async () => { + const interaction = makeInteraction({ + options: { + user: null, + 'bool:confirm': false + } + }); + await command.subcommands['reset-xp'](interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('are-you-sure-you-want-to-delete-server-xp'); + expect(interaction.deferReply).not.toHaveBeenCalled(); + }); + + test('user scope: destroys the target row and confirms', async () => { + const target = { + id: 'target', + toString: () => '<@target>' + }; + const row = { + userID: 'target', + destroy: jest.fn().mockResolvedValue() + }; + const interaction = makeInteraction({ + options: { + user: target, + 'bool:confirm': true + }, + user: row + }); + await command.subcommands['reset-xp'](interaction); + expect(row.destroy).toHaveBeenCalled(); + expect(interaction.editReply.mock.calls[0][0]).toContain('removed-xp-successfully'); + }); + + test('user scope: reports user-not-found when no row exists', async () => { + const target = { + id: 'target', + toString: () => '<@target>' + }; + const interaction = makeInteraction({ + options: { + user: target, + 'bool:confirm': true + }, + user: null + }); + await command.subcommands['reset-xp'](interaction); + expect(interaction.editReply.mock.calls[0][0]).toContain('user-not-found'); + }); + + test('server scope: destroys every row and notifies the log channel', async () => { + const rows = [{destroy: jest.fn().mockResolvedValue()}, {destroy: jest.fn().mockResolvedValue()}]; + const logChannel = {send: jest.fn().mockResolvedValue()}; + const interaction = makeInteraction({ + options: { + user: null, + 'bool:confirm': true + }, + allUsers: rows, + logChannel + }); + await command.subcommands['reset-xp'](interaction); + expect(rows[0].destroy).toHaveBeenCalled(); + expect(rows[1].destroy).toHaveBeenCalled(); + expect(logChannel.send).toHaveBeenCalled(); + expect(interaction.editReply.mock.calls[0][0]).toContain('successfully-deleted-all-xp-of-users'); + }); +}); + +describe('edit-xp', () => { + test('set creates a missing user then applies the absolute value', async () => { + const member = makeMember(); + const interaction = makeInteraction({ + member, + options: {value: 800}, + user: null + }); + await command.subcommands['edit-xp'].set(interaction); + expect(interaction.client.models.levels.User.create).toHaveBeenCalled(); + // 800 xp -> at least level 2 under LINEAR (level*750) + expect(interaction.editReply.mock.calls[0][0].content).toContain('successfully-changed'); + }); + + test('rejects a negative resulting xp', async () => { + const member = makeMember(); + const user = { + userID: 'target', + xp: 100, + level: 1, + save: jest.fn() + }; + const interaction = makeInteraction({ + member, + options: {value: -500}, + user + }); + await command.subcommands['edit-xp'].add(interaction); + expect(interaction.editReply.mock.calls[0][0].content).toContain('negative-xp'); + expect(user.save).not.toHaveBeenCalled(); + }); + + test('rejects xp above the safety ceiling', async () => { + const member = makeMember(); + const user = { + userID: 'target', + xp: 0, + level: 1, + save: jest.fn() + }; + const interaction = makeInteraction({ + member, + options: {value: 2e12}, + user + }); + await command.subcommands['edit-xp'].set(interaction); + expect(interaction.editReply.mock.calls[0][0].content).toContain('xp-out-of-range'); + }); + + test('add raises the level via the threshold loop and saves', async () => { + const member = makeMember(); + const user = { + userID: 'target', + xp: 0, + level: 1, + save: jest.fn().mockResolvedValue() + }; + // +3000 xp under LINEAR: level 4 needs 3000. + const interaction = makeInteraction({ + member, + options: {value: 3000}, + user + }); + await command.subcommands['edit-xp'].add(interaction); + expect(user.level).toBeGreaterThan(1); + expect(user.save).toHaveBeenCalled(); + }); +}); + +describe('edit-level', () => { + test('reports no-profile when the target has no row', async () => { + const member = makeMember(); + const interaction = makeInteraction({ + member, + options: {value: 5}, + user: null + }); + await command.subcommands['edit-level'].set(interaction); + expect(interaction.editReply.mock.calls[0][0].content).toContain('cheat-no-profile'); + }); + + test('rejects a resulting level below 1', async () => { + const member = makeMember(); + const user = { + userID: 'target', + level: 2, + xp: 1500, + save: jest.fn() + }; + const interaction = makeInteraction({ + member, + options: {value: 0}, + user + }); + await command.subcommands['edit-level'].set(interaction); + expect(interaction.editReply.mock.calls[0][0].content).toContain('negative-level'); + }); + + test('set recomputes xp for the new level and reconciles reward roles', async () => { + const member = makeMember(); + const user = { + userID: 'target', + level: 1, + xp: 750, + save: jest.fn().mockResolvedValue() + }; + const interaction = makeInteraction({ + member, + options: {value: 3}, + user, + config: { + reward_roles: { + '2': 'roleTwo', + '3': 'roleThree' + } + } + }); + await command.subcommands['edit-level'].set(interaction); + expect(user.level).toBe(3); + expect(user.xp).toBe(2250); // 3*750 LINEAR + // both reward roles at/under level 3 added + expect(member.roles.add).toHaveBeenCalledWith('roleTwo', expect.any(String)); + expect(member.roles.add).toHaveBeenCalledWith('roleThree', expect.any(String)); + }); + + test('onlyTopLevelRole removes the lower reward when climbing past it', async () => { + const member = makeMember(['roleTwo']); + const user = { + userID: 'target', + level: 1, + xp: 750, + save: jest.fn().mockResolvedValue() + }; + const interaction = makeInteraction({ + member, + options: {value: 3}, + user, + config: { + onlyTopLevelRole: true, + reward_roles: { + '2': 'roleTwo', + '3': 'roleThree' + } + } + }); + await command.subcommands['edit-level'].set(interaction); + expect(member.roles.remove).toHaveBeenCalledWith('roleTwo', expect.any(String)); + expect(member.roles.add).toHaveBeenCalledWith('roleThree', expect.any(String)); + }); + + test('startFromZero offsets a non-zero new level by one', async () => { + const member = makeMember(); + const user = { + userID: 'target', + level: 2, + xp: 1500, + save: jest.fn().mockResolvedValue() + }; + const interaction = makeInteraction({ + member, + options: {value: 5}, + user, + config: {startFromZero: true} + }); + await command.subcommands['edit-level'].set(interaction); + expect(user.level).toBe(6); // 5 + 1 offset + }); +}); \ No newline at end of file diff --git a/tests/levels/messageCreateRun.test.js b/tests/levels/messageCreateRun.test.js new file mode 100644 index 00000000..6d1340d5 --- /dev/null +++ b/tests/levels/messageCreateRun.test.js @@ -0,0 +1,219 @@ +/* + * Tests for the messageCreate.run guard chain (modules/levels/events/ + * messageCreate.js). run() awards message XP via grantXPAndLevelUP, which is the + * first thing to touch models.levels.User.findOne; we use that call as the probe + * for "did we proceed past the guards". Covers: not-ready, bot/system authors, + * no guild / wrong guild, missing member, prefix messages, blacklisted channel + * (incl. parent), blacklisted role, the happy path, and the post-grant cooldown + * that blocks an immediate second message. Helpers are mocked; LINEAR curve. + */ +const mainStub = require('../__stubs__/main'); + +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn(), + randomIntFromInterval: jest.fn(() => 10), + randomElementFromArray: jest.fn((a) => a[0]), + embedTypeV2: jest.fn(async (m) => m), + formatDiscordUserName: (u) => u.username, + formatNumber: (n) => String(n), + todayInServerTZ: () => '2026-06-02', + formatVoiceDuration: (s) => `${s}s` +})); +jest.mock('discord.js', () => ({ChannelType: {GuildText: 0}})); +jest.mock('../../modules/levels/leaderboardChannel', () => ({registerNeededEdit: jest.fn()})); + +const handler = require('../../modules/levels/events/messageCreate'); + +// run() schedules a cooldown-clearing setTimeout; fake timers stop it leaking +// past the test (and let us assert the cooldown is active mid-window). +beforeEach(() => jest.useFakeTimers()); +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +let userFindOne; + +function makeClient() { + userFindOne = jest.fn().mockResolvedValue({ + userID: 'u1', + xp: 0, + level: 1, + messages: 0, + dailyMessages: 0, + dailyVoiceSeconds: 0, + dailyResetDate: '2026-06-02', + save: jest.fn().mockResolvedValue() + }); + const conf = { + levels: { + config: { + curveType: 'LINEAR', + startFromZero: false, + maximumLevelEnabled: false, + blacklisted_channels: [], + blacklistedRoles: [], + multiplication_roles: {}, + multiplication_channels: {}, + reward_roles: {}, + 'min-xp': 10, + 'max-xp': 10, + cooldown: 60000, + levelUpMessagesConditions: 'all' + }, + strings: { + level_up_message: 'x', + level_up_message_with_reward: 'y' + }, + 'special-levelup-messages': [], + 'random-levelup-messages': [] + } + }; + mainStub.client.configurations = conf; + return { + botReadyAt: Date.now(), + guildID: 'g1', + config: {prefix: '!'}, + configurations: conf, + logger: {error: jest.fn()}, + channels: {cache: {find: () => null}}, + models: { + levels: { + User: { + findOne: userFindOne, + create: jest.fn() + } + } + } + }; +} + +function makeMsg({ + content = 'hello', + authorId = 'u1', + bot = false, + system = false, + guildId = 'g1', + hasMember = true, + channelId = 'c1', + parentId = null + } = {}) { + const roleCache = new Map(); + roleCache.some = () => false; + roleCache.filter = () => ({values: () => [][Symbol.iterator]()}); + return { + author: { + id: authorId, + bot, + username: 'U', + avatarURL: () => 'a', + defaultAvatarURL: 'd' + }, + system, + guild: guildId ? {id: guildId} : null, + member: hasMember ? { + client: undefined, + user: { + id: authorId, + username: 'U', + avatarURL: () => 'a', + defaultAvatarURL: 'd' + }, + roles: {cache: roleCache} + } : null, + content, + channel: { + id: channelId, + parentId, + parent: null, + send: jest.fn().mockResolvedValue() + }, + reply: jest.fn().mockResolvedValue() + }; +} + +function proceeded() { + return userFindOne.mock.calls.length > 0; +} + +test('ignores messages before the bot is ready', async () => { + const client = makeClient(); + client.botReadyAt = null; + await handler.run(client, makeMsg()); + expect(proceeded()).toBe(false); +}); + +test('ignores bot and system authors', async () => { + let client = makeClient(); + await handler.run(client, makeMsg({bot: true})); + expect(proceeded()).toBe(false); + client = makeClient(); + await handler.run(client, makeMsg({system: true})); + expect(proceeded()).toBe(false); +}); + +test('ignores messages without a guild or from the wrong guild', async () => { + let client = makeClient(); + await handler.run(client, makeMsg({guildId: null})); + expect(proceeded()).toBe(false); + client = makeClient(); + await handler.run(client, makeMsg({guildId: 'other'})); + expect(proceeded()).toBe(false); +}); + +test('ignores messages with no resolvable member', async () => { + const client = makeClient(); + await handler.run(client, makeMsg({hasMember: false})); + expect(proceeded()).toBe(false); +}); + +test('ignores messages containing the command prefix', async () => { + const client = makeClient(); + await handler.run(client, makeMsg({content: 'do !thing'})); + expect(proceeded()).toBe(false); +}); + +test('ignores messages in a blacklisted channel', async () => { + const client = makeClient(); + client.configurations.levels.config.blacklisted_channels = ['c1']; + const msg = makeMsg({channelId: 'c1'}); + msg.member.client = client; + await handler.run(client, msg); + expect(proceeded()).toBe(false); +}); + +test('ignores messages in a channel whose parent is blacklisted', async () => { + const client = makeClient(); + client.configurations.levels.config.blacklisted_channels = ['cat']; + const msg = makeMsg({ + channelId: 'c1', + parentId: 'cat' + }); + msg.member.client = client; + await handler.run(client, msg); + expect(proceeded()).toBe(false); +}); + +test('ignores members holding a blacklisted role', async () => { + const client = makeClient(); + client.configurations.levels.config.blacklistedRoles = ['bad']; + const msg = makeMsg(); + msg.member.roles.cache.some = (fn) => fn({id: 'bad'}); + msg.member.client = client; + await handler.run(client, msg); + expect(proceeded()).toBe(false); +}); + +test('awards xp on a normal message and then cools the author down', async () => { + const client = makeClient(); + const msg = makeMsg({authorId: 'fresh'}); + msg.member.client = client; + await handler.run(client, msg); + expect(proceeded()).toBe(true); + // Second immediate message from the same author is blocked by the cooldown set. + userFindOne.mockClear(); + const msg2 = makeMsg({authorId: 'fresh'}); + msg2.member.client = client; + await handler.run(client, msg2); + expect(userFindOne).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/levels/models.test.js b/tests/levels/models.test.js new file mode 100644 index 00000000..b7746fc8 --- /dev/null +++ b/tests/levels/models.test.js @@ -0,0 +1,97 @@ +/* + * Schema tests for the levels sequelize models (User, LiveLeaderboard). + * sequelize is mocked so Model.init captures attributes/options. Asserts the + * table names, the userID/channelID primary keys, the level default of 1, and + * the daily-counter columns (default 0, NOT NULL) plus the nullable reset date - + * the constraints the daily-reset logic in messageCreate relies on. + */ +jest.mock('sequelize', () => { + const captured = []; + + class Model { + static init(attrs, opts) { + captured.push({ + attrs, + opts + }); + return { + attrs, + opts + }; + } + } + + const DataTypes = new Proxy({}, {get: (_t, p) => String(p)}); + return { + Model, + DataTypes, + __captured: captured + }; +}); + +const seq = require('sequelize'); + +function initModel(model) { + seq.__captured.length = 0; + model.init({}); + return seq.__captured[0]; +} + +describe('levels User model', () => { + const User = require('../../modules/levels/models/User'); + const { + attrs, + opts + } = initModel(User); + + test('stored in the levels_users table with timestamps', () => { + expect(opts.tableName).toBe('levels_users'); + expect(opts.timestamps).toBe(true); + }); + test('userID is the string primary key', () => { + expect(attrs.userID.type).toBe('STRING'); + expect(attrs.userID.primaryKey).toBe(true); + }); + test('level defaults to 1', () => { + expect(attrs.level.type).toBe('INTEGER'); + expect(attrs.level.defaultValue).toBe(1); + }); + test('daily counters default to 0 and are NOT NULL', () => { + expect(attrs.dailyMessages.defaultValue).toBe(0); + expect(attrs.dailyMessages.allowNull).toBe(false); + expect(attrs.dailyVoiceSeconds.defaultValue).toBe(0); + expect(attrs.dailyVoiceSeconds.allowNull).toBe(false); + }); + test('dailyResetDate is a nullable string', () => { + expect(attrs.dailyResetDate.type).toBe('STRING'); + expect(attrs.dailyResetDate.allowNull).toBe(true); + }); + test('exports loader config', () => { + expect(User.config).toEqual({ + name: 'User', + module: 'levels' + }); + }); +}); + +describe('levels LiveLeaderboard model', () => { + const LiveLeaderboard = require('../../modules/levels/models/LiveLeaderboard'); + const { + attrs, + opts + } = initModel(LiveLeaderboard); + + test('stored in the levels_liveleaderboard table', () => { + expect(opts.tableName).toBe('levels_liveleaderboard'); + }); + test('channelID is the string primary key, messageID a plain string', () => { + expect(attrs.channelID.primaryKey).toBe(true); + expect(attrs.messageID).toBe('STRING'); + }); + test('exports loader config', () => { + expect(LiveLeaderboard.config).toEqual({ + name: 'LiveLeaderboard', + module: 'levels' + }); + }); +}); \ No newline at end of file diff --git a/tests/levels/profileCommand.test.js b/tests/levels/profileCommand.test.js new file mode 100644 index 00000000..b491ebad --- /dev/null +++ b/tests/levels/profileCommand.test.js @@ -0,0 +1,206 @@ +/* + * Tests for the /profile command (modules/levels/commands/profile.js). Covers: + * - user-not-found early reply when the target has no levels row. + * - the happy path embed (messages/xp/level fields + joinedAt). + * - the daily-counters reset display when the stored reset date is stale. + * - the role-factor field, which only appears when a member holds multiplier + * roles (getMemberRoleFactor !== 1). + * MessageEmbed and helpers are mocked so we can assert on the field set. + */ +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn((i) => ({_embedType: i})), + formatDate: (d) => `date:${d}`, + formatNumber: (n) => String(n), + parseEmbedColor: (c) => c, + safeSetFooter: jest.fn(), + formatVoiceDuration: (s) => `${s}s`, + todayInServerTZ: () => '2026-06-02' +})); +jest.mock('discord.js', () => { + class MessageEmbed { + constructor() { + this.fields = []; + this.data = {}; + } + + setColor(c) { + this.data.color = c; + return this; + } + + setThumbnail(t) { + this.data.thumbnail = t; + return this; + } + + setTitle(t) { + this.data.title = t; + return this; + } + + setDescription(d) { + this.data.description = d; + return this; + } + + addField(name, value, inline) { + this.fields.push({ + name, + value, + inline + }); + return this; + } + } + + return {MessageEmbed}; +}); + +const command = require('../../modules/levels/commands/profile'); + +const strings = { + embed: { + color: 'GREEN', + title: '%username%', + description: '%username%', + messages: 'Messages', + xp: 'XP', + level: 'Level', + messagesToday: 'MsgToday', + voiceTimeToday: 'VoiceToday', + roleFactor: 'RoleFactor', + joinedAt: 'JoinedAt' + }, + user_not_found: 'no-user' +}; + +function makeMember({ + roleIds = [], + multRoles = {} + } = {}) { + const cache = new Map(roleIds.map(id => [id, {id}])); + cache.filter = (fn) => { + const arr = [...cache.values()].filter(fn); + return {values: () => arr[Symbol.iterator]()}; + }; + const member = { + user: { + id: 'u1', + username: 'Alice', + avatarURL: () => 'a' + }, + joinedAt: new Date('2025-01-01'), + roles: {cache} + }; + // getMemberRoleFactor reads member.client.configurations; link it lazily. + return member; +} + +function makeInteraction({ + user, + member, + config = {} + } = {}) { + const client = { + configurations: { + levels: { + config: { + curveType: 'LINEAR', + maximumLevelEnabled: false, + startFromZero: false, + multiplication_roles: {}, ...config + }, + strings + } + } + }; + if (member) member.client = client; + return { + member, + options: {getUser: () => null}, + guild: {members: {fetch: jest.fn()}}, + client, + models: undefined, + reply: jest.fn().mockResolvedValue() + }; +} + +function attachModels(interaction, user) { + interaction.client.models = {levels: {User: {findOne: jest.fn().mockResolvedValue(user)}}}; +} + +test('replies user_not_found when the member has no levels row', async () => { + const interaction = makeInteraction({member: makeMember()}); + attachModels(interaction, null); + await command.run(interaction); + expect(interaction.reply.mock.calls[0][0]._embedType).toBe('no-user'); +}); + +test('builds a profile embed with messages, xp, level and joinedAt', async () => { + const interaction = makeInteraction({member: makeMember()}); + attachModels(interaction, { + level: 3, + xp: 5000, + messages: 42, + dailyResetDate: '2026-06-02', + dailyMessages: 4, + dailyVoiceSeconds: 120 + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + const names = embed.fields.map(f => f.name); + expect(names).toEqual(expect.arrayContaining(['Messages', 'XP', 'Level', 'MsgToday', 'VoiceToday', 'JoinedAt'])); + expect(embed.fields.find(f => f.name === 'Messages').value).toBe('42'); +}); + +test('shows 0 daily counters when the stored reset date is stale', async () => { + const interaction = makeInteraction({member: makeMember()}); + attachModels(interaction, { + level: 1, + xp: 0, + messages: 1, + dailyResetDate: '2026-01-01', + dailyMessages: 99, + dailyVoiceSeconds: 500 + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + expect(embed.fields.find(f => f.name === 'MsgToday').value).toBe('0'); + expect(embed.fields.find(f => f.name === 'VoiceToday').value).toBe('0s'); +}); + +test('adds the role-factor field when the member has multiplier roles', async () => { + const member = makeMember({roleIds: ['boost']}); + const interaction = makeInteraction({ + member, + config: {multiplication_roles: {boost: '2'}} + }); + attachModels(interaction, { + level: 2, + xp: 100, + messages: 5, + dailyResetDate: '2026-06-02', + dailyMessages: 0, + dailyVoiceSeconds: 0 + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + const rf = embed.fields.find(f => f.name === 'RoleFactor'); + expect(rf).toBeDefined(); + expect(rf.value).toContain('<@&boost>: 2x'); +}); + +test('omits the role-factor field when factor is 1', async () => { + const interaction = makeInteraction({member: makeMember()}); + attachModels(interaction, { + level: 2, + xp: 100, + messages: 5, + dailyResetDate: '2026-06-02', + dailyMessages: 0, + dailyVoiceSeconds: 0 + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + expect(embed.fields.find(f => f.name === 'RoleFactor')).toBeUndefined(); +}); \ No newline at end of file diff --git a/tests/levels/voiceEligibility.test.js b/tests/levels/voiceEligibility.test.js new file mode 100644 index 00000000..90d9309d --- /dev/null +++ b/tests/levels/voiceEligibility.test.js @@ -0,0 +1,164 @@ +/* + * Tests for the voice-XP eligibility helpers extracted from + * modules/levels/events/voiceStateUpdate.js. These pure predicates decide + * whether a member should earn voice XP: + * - isChannelBlacklisted: blacklist by channel, parent category or grandparent. + * - isRoleBlacklisted: blacklist by any held role (string/number id coercion). + * - hasHumanCompany: at least two non-bot members must share the channel. + * - isEligible: combines the above plus mute/deaf and stage-channel checks. + */ + +const {ChannelType} = require('discord.js'); +const { + isChannelBlacklisted, + isRoleBlacklisted, + hasHumanCompany, + isEligible +} = require('../../modules/levels/events/voiceStateUpdate'); + +function makeClient({ + blacklistedChannels = [], + blacklistedRoles = [] + } = {}) { + return { + configurations: { + levels: { + config: { + blacklisted_channels: blacklistedChannels, + blacklistedRoles + } + } + } + }; +} + +function makeChannel({ + id = 'c1', + parentId = null, + grandParentId = null, + members = [] + } = {}) { + return { + id, + parentId, + parent: parentId ? {parentId: grandParentId} : null, + type: ChannelType.GuildVoice, + members: { + filter(fn) { + return {size: members.filter(fn).length}; + } + } + }; +} + +describe('isChannelBlacklisted', () => { + test('treats a missing channel as blacklisted', () => { + expect(isChannelBlacklisted(makeClient(), null)).toBe(true); + }); + + test('blacklists by channel id', () => { + const client = makeClient({blacklistedChannels: ['c1']}); + expect(isChannelBlacklisted(client, makeChannel({id: 'c1'}))).toBe(true); + }); + + test('blacklists by parent category', () => { + const client = makeClient({blacklistedChannels: ['cat']}); + expect(isChannelBlacklisted(client, makeChannel({ + id: 'c1', + parentId: 'cat' + }))).toBe(true); + }); + + test('allows a non-blacklisted channel', () => { + const client = makeClient({blacklistedChannels: ['other']}); + expect(isChannelBlacklisted(client, makeChannel({ + id: 'c1', + parentId: 'cat' + }))).toBe(false); + }); +}); + +describe('isRoleBlacklisted', () => { + function makeMember(roleIds) { + const roles = roleIds.map(id => ({id})); + return {roles: {cache: {some: fn => roles.some(fn)}}}; + } + + test('true when a held role is blacklisted (numeric config coerced to string)', () => { + const client = makeClient({blacklistedRoles: [123]}); + expect(isRoleBlacklisted(client, makeMember(['123']))).toBe(true); + }); + + test('false when no held role is blacklisted', () => { + const client = makeClient({blacklistedRoles: ['999']}); + expect(isRoleBlacklisted(client, makeMember(['1', '2']))).toBe(false); + }); +}); + +describe('hasHumanCompany', () => { + test('false when fewer than 2 humans present', () => { + const channel = makeChannel({members: [{user: {bot: false}}, {user: {bot: true}}]}); + expect(hasHumanCompany(channel)).toBe(false); + }); + + test('true with 2 or more humans', () => { + const channel = makeChannel({members: [{user: {bot: false}}, {user: {bot: false}}]}); + expect(hasHumanCompany(channel)).toBe(true); + }); + + test('false for a null channel', () => { + expect(hasHumanCompany(null)).toBe(false); + }); +}); + +describe('isEligible', () => { + function eligibleState() { + return { + channel: makeChannel({members: [{user: {bot: false}}, {user: {bot: false}}]}), + member: { + user: {bot: false}, + roles: {cache: {some: () => false}} + }, + deaf: false, + mute: false + }; + } + + test('eligible for a normal active member with company', () => { + expect(isEligible(makeClient(), eligibleState())).toBe(true); + }); + + test('not eligible when muted', () => { + const state = eligibleState(); + state.mute = true; + expect(isEligible(makeClient(), state)).toBe(false); + }); + + test('not eligible when deafened', () => { + const state = eligibleState(); + state.deaf = true; + expect(isEligible(makeClient(), state)).toBe(false); + }); + + test('not eligible for bots', () => { + const state = eligibleState(); + state.member.user.bot = true; + expect(isEligible(makeClient(), state)).toBe(false); + }); + + test('not eligible in a stage channel', () => { + const state = eligibleState(); + state.channel.type = ChannelType.GuildStageVoice; + expect(isEligible(makeClient(), state)).toBe(false); + }); + + test('not eligible without human company', () => { + const state = eligibleState(); + state.channel = makeChannel({members: [{user: {bot: false}}]}); + expect(isEligible(makeClient(), state)).toBe(false); + }); + + test('not eligible with no channel', () => { + expect(isEligible(makeClient(), {channel: null})).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/levels/voiceStateUpdateRun.test.js b/tests/levels/voiceStateUpdateRun.test.js new file mode 100644 index 00000000..0dd302ea --- /dev/null +++ b/tests/levels/voiceStateUpdateRun.test.js @@ -0,0 +1,131 @@ +/* + * Tests for the voiceStateUpdate.run guard chain (modules/levels/events/ + * voiceStateUpdate.js). run() only does work when a real channel/mute/deaf change + * happened in this guild with voice XP enabled. We probe "did we proceed" by + * whether the new channel's members collection was iterated (updateChannelSessions + * calls channel.members.values()). Covers: not-ready, no-guild/bot member, wrong + * guild, voiceXPPerMinute=0, and the no-change early return; plus the proceed + * case on an actual join. grantXPAndLevelUP's deps are mocked away via helpers. + */ +const mainStub = require('../__stubs__/main'); +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn(), + randomIntFromInterval: jest.fn(() => 1), + randomElementFromArray: (a) => a[0], + embedTypeV2: jest.fn(async (m) => m), + formatDiscordUserName: (u) => u.username, + formatNumber: (n) => String(n), + todayInServerTZ: () => '2026-06-02', + formatVoiceDuration: (s) => `${s}s` +})); +jest.mock('discord.js', () => ({ + ChannelType: { + GuildVoice: 2, + GuildStageVoice: 13 + } +})); + +const handler = require('../../modules/levels/events/voiceStateUpdate'); + +afterEach(() => jest.useRealTimers()); + +function makeClient(voiceXP = 1) { + const conf = { + levels: { + config: { + voiceXPPerMinute: voiceXP, + blacklisted_channels: [], + blacklistedRoles: [] + } + } + }; + mainStub.client.configurations = conf; + return { + botReadyAt: Date.now(), + guildID: 'g1', + configurations: conf, + logger: {error: jest.fn()} + }; +} + +let iterated; + +function makeChannel(id, members = []) { + return { + id, + members: { + values: () => { + iterated = true; + return members[Symbol.iterator](); + }, + filter: (fn) => ({size: members.filter(fn).length}) + } + }; +} + +function state({ + channel = null, + guildId = 'g1', + bot = false, + deaf = false, + mute = false, + memberId = 'm1' + } = {}) { + return { + guild: guildId ? {id: guildId} : null, + channel, + deaf, + mute, + member: { + id: memberId, + user: {bot}, + voice: {}, + roles: {cache: {some: () => false}} + } + }; +} + +beforeEach(() => { + iterated = false; +}); + +test('ignores when the bot is not ready', async () => { + const client = makeClient(); + client.botReadyAt = null; + await handler.run(client, state(), state({channel: makeChannel('v1')})); + expect(iterated).toBe(false); +}); + +test('ignores a bot member', async () => { + await handler.run(makeClient(), state({bot: true}), state({ + channel: makeChannel('v1'), + bot: true + })); + expect(iterated).toBe(false); +}); + +test('ignores the wrong guild', async () => { + await handler.run(makeClient(), state(), state({ + channel: makeChannel('v1'), + guildId: 'other' + })); + expect(iterated).toBe(false); +}); + +test('ignores when voiceXPPerMinute is 0', async () => { + await handler.run(makeClient(0), state(), state({channel: makeChannel('v1')})); + expect(iterated).toBe(false); +}); + +test('returns early when neither channel nor mute/deaf changed', async () => { + const chan = makeChannel('v1'); + await handler.run(makeClient(), state({channel: chan}), state({channel: chan})); + expect(iterated).toBe(false); +}); + +test('proceeds to scan the new channel on a genuine join', async () => { + jest.useFakeTimers(); + const chan = makeChannel('v1', []); // empty -> no eligible member, but it is still iterated + await handler.run(makeClient(), state({channel: null}), state({channel: chan})); + expect(iterated).toBe(true); +}); \ No newline at end of file diff --git a/tests/massrole/massrole.test.js b/tests/massrole/massrole.test.js new file mode 100644 index 00000000..c622bf87 --- /dev/null +++ b/tests/massrole/massrole.test.js @@ -0,0 +1,242 @@ +/* + * Tests for the /massrole command (modules/massrole/commands/massrole.js): + * - beforeSubcommand: rejects members without an admin role. + * - checkTarget: maps the "target" option to all / bots / humans (default all). + * - add/remove/remove-all subcommands: defer first, then iterate members, + * applying the role only to the targeted subset (bots / humans / everyone), + * and report done vs not-done based on the failure count. + */ + +const command = require('../../modules/massrole/commands/massrole'); + +// The string overload of embedType returns {content, allowedMentions}. +function lastEditContent(interaction) { + const calls = interaction.editReply.mock.calls; + const arg = calls[calls.length - 1][0]; + return typeof arg === 'string' ? arg : arg.content; +} + +function makeConfig() { + return { + configurations: { + massrole: { + config: {adminRoles: ['admin']}, + strings: { + done: 'massrole-done', + notDone: 'massrole-not-done' + } + } + } + }; +} + +function makeMember({ + id, + bot = false, + manageable = true, + addImpl, + removeImpl + } = {}) { + return { + id, + user: {bot}, + manageable, + roles: { + cache: {filter: () => 'kept-roles'}, + add: addImpl || jest.fn().mockResolvedValue(), + remove: removeImpl || jest.fn().mockResolvedValue() + } + }; +} + +function makeInteraction({ + members, + target = null, + replied = false + } = {}) { + const cache = new Map(members.map(m => [m.id, m])); + return { + replied, + client: makeConfig(), + user: {tag: 'Admin#0001'}, + options: { + getString: name => (name === 'target' ? target : null), + getRole: () => ({id: 'role1'}) + }, + guild: { + members: { + fetch: jest.fn().mockResolvedValue(), + cache + } + }, + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue() + }; +} + +describe('beforeSubcommand admin check', () => { + function interactionWithRoles(roleIds) { + const roles = roleIds.map(id => ({id})); + return { + client: makeConfig(), + member: {roles: {cache: {filter: fn => ({size: roles.filter(fn).length})}}}, + reply: jest.fn().mockResolvedValue() + }; + } + + test('rejects a member without an admin role', async () => { + const interaction = interactionWithRoles(['member']); + await command.beforeSubcommand(interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ + ephemeral: true, + content: 'massrole.not-admin' + })); + }); + + test('allows a member with an admin role (no reply)', async () => { + const interaction = interactionWithRoles(['admin']); + await command.beforeSubcommand(interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + }); +}); + +describe('checkTarget', () => { + const make = target => ({options: {getString: () => target}}); + test('defaults to all when unset', () => { + expect(command.checkTarget(make(null))).toBe('all'); + }); + test('maps "all"', () => expect(command.checkTarget(make('all'))).toBe('all')); + test('maps "bots"', () => expect(command.checkTarget(make('bots'))).toBe('bots')); + test('maps "humans"', () => expect(command.checkTarget(make('humans'))).toBe('humans')); +}); + +describe('add subcommand', () => { + test('defers before applying roles and adds to every member when target=all', async () => { + const m1 = makeMember({id: '1'}); + const m2 = makeMember({ + id: '2', + bot: true + }); + const interaction = makeInteraction({ + members: [m1, m2], + target: 'all' + }); + await command.subcommands.add(interaction); + + expect(interaction.deferReply).toHaveBeenCalledTimes(1); + const deferOrder = interaction.deferReply.mock.invocationCallOrder[0]; + expect(m1.roles.add.mock.invocationCallOrder[0]).toBeGreaterThan(deferOrder); + expect(m1.roles.add).toHaveBeenCalled(); + expect(m2.roles.add).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledTimes(1); + }); + + test('target=bots only touches bot members', async () => { + const human = makeMember({ + id: '1', + bot: false + }); + const bot = makeMember({ + id: '2', + bot: true + }); + const interaction = makeInteraction({ + members: [human, bot], + target: 'bots' + }); + await command.subcommands.add(interaction); + expect(human.roles.add).not.toHaveBeenCalled(); + expect(bot.roles.add).toHaveBeenCalled(); + }); + + test('target=humans skips bots and non-manageable members', async () => { + const human = makeMember({ + id: '1', + bot: false, + manageable: true + }); + const bot = makeMember({ + id: '2', + bot: true, + manageable: true + }); + const unmanageable = makeMember({ + id: '3', + bot: false, + manageable: false + }); + const interaction = makeInteraction({ + members: [human, bot, unmanageable], + target: 'humans' + }); + await command.subcommands.add(interaction); + expect(human.roles.add).toHaveBeenCalled(); + expect(bot.roles.add).not.toHaveBeenCalled(); + expect(unmanageable.roles.add).not.toHaveBeenCalled(); + }); + + test('reports not-done when a role add throws', async () => { + const failing = makeMember({ + id: '1', + addImpl: jest.fn().mockRejectedValue(new Error('no perms')) + }); + const interaction = makeInteraction({ + members: [failing], + target: 'all' + }); + await command.subcommands.add(interaction); + // a failed role add must surface the not-done message + expect(lastEditContent(interaction)).toBe('massrole-not-done'); + }); + + test('does nothing if the interaction was already replied', async () => { + const m1 = makeMember({id: '1'}); + const interaction = makeInteraction({ + members: [m1], + target: 'all', + replied: true + }); + await command.subcommands.add(interaction); + expect(interaction.deferReply).not.toHaveBeenCalled(); + expect(m1.roles.add).not.toHaveBeenCalled(); + }); +}); + +describe('remove subcommand', () => { + test('removes the role from bot members for target=bots', async () => { + const human = makeMember({ + id: '1', + bot: false + }); + const bot = makeMember({ + id: '2', + bot: true + }); + const interaction = makeInteraction({ + members: [human, bot], + target: 'bots' + }); + await command.subcommands.remove(interaction); + expect(bot.roles.remove).toHaveBeenCalled(); + expect(human.roles.remove).not.toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledTimes(1); + }); +}); + +describe('remove-all subcommand', () => { + test('removes the filtered (non-managed) role set from each targeted member', async () => { + const human = makeMember({ + id: '1', + bot: false, + manageable: true + }); + const interaction = makeInteraction({ + members: [human], + target: 'humans' + }); + await command.subcommands['remove-all'](interaction); + // first arg is the filtered cache result from member.roles.cache.filter(...) + expect(human.roles.remove).toHaveBeenCalledWith('kept-roles', expect.any(String)); + }); +}); \ No newline at end of file diff --git a/tests/migrations/DatabaseSchemeVersionStorage.test.js b/tests/migrations/DatabaseSchemeVersionStorage.test.js new file mode 100644 index 00000000..cf26508d --- /dev/null +++ b/tests/migrations/DatabaseSchemeVersionStorage.test.js @@ -0,0 +1,156 @@ +const { + Sequelize, + DataTypes, + Model +} = require('sequelize'); +const DatabaseSchemeVersionStorage = require('../../src/functions/migrations/DatabaseSchemeVersionStorage'); +const { + parseMigrationName, + versionNumber +} = DatabaseSchemeVersionStorage; + +function makeMarkerModel() { + const sequelize = new Sequelize({dialect: 'sqlite', storage: ':memory:', logging: false}); + + class DatabaseSchemeVersion extends Model { + } + + DatabaseSchemeVersion.init({ + model: { + type: DataTypes.STRING, + primaryKey: true + }, + version: DataTypes.STRING + }, { + sequelize, + tableName: 'system_DatabaseSchemeVersion', + timestamps: true + }); + return { + DatabaseSchemeVersion, + sequelize + }; +} + +describe('parseMigrationName', () => { + test('splits on the last double-underscore', () => { + expect(parseMigrationName('levels_User__V1')).toEqual({ + model: 'levels_User', + version: 'V1' + }); + expect(parseMigrationName('staff-management-system_ActivityCheck__V3')).toEqual({ + model: 'staff-management-system_ActivityCheck', + version: 'V3' + }); + }); + + test('returns null when the name has no separator', () => { + expect(parseMigrationName('levels_User')).toBeNull(); + }); +}); + +describe('versionNumber', () => { + test.each([ + ['V1', 1], + ['V12', 12], + ['V0', 0] + ])('parses %s', (input, expected) => { + expect(versionNumber(input)).toBe(expected); + }); + + test.each(['v1', '1', 'V1a', '', 'applied'])('rejects %s', (input) => { + expect(versionNumber(input)).toBeNull(); + }); +}); + +describe('DatabaseSchemeVersionStorage', () => { + let DatabaseSchemeVersion; + let sequelize; + let storage; + + beforeEach(async () => { + ({ + DatabaseSchemeVersion, + sequelize + } = makeMarkerModel()); + await sequelize.sync(); + storage = new DatabaseSchemeVersionStorage({getModel: () => DatabaseSchemeVersion}); + }); + + afterEach(async () => { + await sequelize.close(); + }); + + test('executed() is empty on a fresh table', async () => { + expect(await storage.executed()).toEqual([]); + }); + + test('logMigration writes a new-format row', async () => { + await storage.logMigration({name: 'levels_User__V1'}); + + const row = await DatabaseSchemeVersion.findOne({where: {model: 'levels_User__V1'}}); + expect(row).not.toBeNull(); + expect(row.version).toBe('applied'); + }); + + test('executed() returns new-format rows verbatim', async () => { + await storage.logMigration({name: 'levels_User__V1'}); + await storage.logMigration({name: 'levels_User__V2'}); + + expect((await storage.executed()).sort()).toEqual(['levels_User__V1', 'levels_User__V2']); + }); + + test('executed() expands a legacy row to all lower-numbered versions', async () => { + await DatabaseSchemeVersion.create({ + model: 'birthday_User', + version: 'V2' + }); + + expect((await storage.executed()).sort()).toEqual(['birthday_User__V1', 'birthday_User__V2']); + }); + + test('executed() merges legacy and new-format rows for the same model', async () => { + await DatabaseSchemeVersion.create({ + model: 'levels_User', + version: 'V1' + }); + await storage.logMigration({name: 'levels_User__V2'}); + + expect((await storage.executed()).sort()).toEqual(['levels_User__V1', 'levels_User__V2']); + }); + + test('executed() handles a legacy row with a non-numeric version by passing it through', async () => { + await DatabaseSchemeVersion.create({ + model: 'odd_model', + version: 'something-weird' + }); + + expect(await storage.executed()).toEqual(['odd_model__something-weird']); + }); + + test('unlogMigration removes the new-format row and any matching legacy row', async () => { + await DatabaseSchemeVersion.create({ + model: 'levels_User', + version: 'V1' + }); + await storage.logMigration({name: 'levels_User__V1'}); + + await storage.unlogMigration({name: 'levels_User__V1'}); + + expect(await DatabaseSchemeVersion.findOne({where: {model: 'levels_User__V1'}})).toBeNull(); + expect(await DatabaseSchemeVersion.findOne({ + where: { + model: 'levels_User', + version: 'V1' + } + })).toBeNull(); + }); + + test('logMigration is idempotent (upsert)', async () => { + await storage.logMigration({name: 'levels_User__V1'}); + await storage.logMigration({name: 'levels_User__V1'}); + + const rows = await DatabaseSchemeVersion.findAll({where: {model: 'levels_User__V1'}}); + expect(rows).toHaveLength(1); + }); +}); \ No newline at end of file diff --git a/tests/migrations/backup.test.js b/tests/migrations/backup.test.js new file mode 100644 index 00000000..b3264a4e --- /dev/null +++ b/tests/migrations/backup.test.js @@ -0,0 +1,232 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + Sequelize, + DataTypes +} = require('sequelize'); +const { + backupTables, + backupTable, + pruneOldBackups, + backupDir +} = require('../../src/functions/migrations/backup'); + +function noop() { +} + +function makeClient(dataDir) { + return { + dataDir, + logger: { + info: noop, + warn: noop, + error: noop, + debug: noop + } + }; +} + +async function makeSequelizeWithUsers() { + const sequelize = new Sequelize({dialect: 'sqlite', storage: ':memory:', logging: false}); + const queryInterface = sequelize.getQueryInterface(); + await queryInterface.createTable('users', { + id: { + type: DataTypes.STRING, + primaryKey: true + }, + name: DataTypes.STRING, + score: DataTypes.INTEGER + }); + return sequelize; +} + +describe('backupTable / backupTables', () => { + let tmpDataDir; + let client; + + beforeEach(() => { + tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-backup-')); + client = makeClient(tmpDataDir); + }); + + afterEach(() => { + fs.rmSync(tmpDataDir, { + recursive: true, + force: true + }); + }); + + test('writes a JSON snapshot of a populated table and returns its path', async () => { + const sequelize = await makeSequelizeWithUsers(); + await sequelize.query('INSERT INTO users (id, name, score) VALUES (?, ?, ?), (?, ?, ?)', + {replacements: ['1', 'Alice', 42, '2', 'Bob', 17]}); + + const filepath = await backupTable(client, sequelize, 'users_User__V1', 'users'); + + expect(filepath).not.toBeNull(); + expect(fs.existsSync(filepath)).toBe(true); + const content = JSON.parse(fs.readFileSync(filepath, 'utf8')); + expect(content).toEqual([ + { + id: '1', + name: 'Alice', + score: 42 + }, + { + id: '2', + name: 'Bob', + score: 17 + } + ]); + expect(path.basename(filepath)).toMatch(/__users_User__V1__users\.json$/u); + + await sequelize.close(); + }); + + test('skips empty tables (no file written)', async () => { + const sequelize = await makeSequelizeWithUsers(); + const filepath = await backupTable(client, sequelize, 'users_User__V1', 'users'); + expect(filepath).toBeNull(); + const dir = backupDir(client); + if (fs.existsSync(dir)) expect(fs.readdirSync(dir)).toEqual([]); + await sequelize.close(); + }); + + test('skips tables that do not exist (no throw)', async () => { + const sequelize = await makeSequelizeWithUsers(); + const filepath = await backupTable(client, sequelize, 'users_User__V1', 'does_not_exist'); + expect(filepath).toBeNull(); + await sequelize.close(); + }); + + test('backupTables iterates the list and returns paths for the non-empty ones', async () => { + const sequelize = await makeSequelizeWithUsers(); + await sequelize.query('INSERT INTO users (id, name, score) VALUES (?, ?, ?)', {replacements: ['1', 'A', 1]}); + const queryInterface = sequelize.getQueryInterface(); + await queryInterface.createTable('empty_table', { + id: { + type: DataTypes.STRING, + primaryKey: true + } + }); + + const paths = await backupTables(client, sequelize, 'mig__V1', ['users', 'empty_table', 'missing_table']); + + expect(paths).toHaveLength(1); + expect(paths[0]).toMatch(/__users\.json$/u); + await sequelize.close(); + }); + + test('backupTables with empty or non-array tables list is a no-op', async () => { + const sequelize = await makeSequelizeWithUsers(); + expect(await backupTables(client, sequelize, 'mig__V1', [])).toEqual([]); + expect(await backupTables(client, sequelize, 'mig__V1', null)).toEqual([]); + let absent; + expect(await backupTables(client, sequelize, 'mig__V1', absent)).toEqual([]); + await sequelize.close(); + }); + + test('creates the backup directory if it does not exist', async () => { + const sequelize = await makeSequelizeWithUsers(); + await sequelize.query('INSERT INTO users (id, name, score) VALUES (?, ?, ?)', {replacements: ['1', 'A', 1]}); + + expect(fs.existsSync(backupDir(client))).toBe(false); + await backupTable(client, sequelize, 'mig__V1', 'users'); + expect(fs.existsSync(backupDir(client))).toBe(true); + + await sequelize.close(); + }); +}); + +describe('pruneOldBackups', () => { + let tmpDataDir; + let client; + + beforeEach(() => { + tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-prune-')); + client = makeClient(tmpDataDir); + }); + + afterEach(() => { + fs.rmSync(tmpDataDir, { + recursive: true, + force: true + }); + }); + + test('does nothing when the backup directory does not exist', async () => { + const deleted = await pruneOldBackups(client, 5); + expect(deleted).toEqual([]); + }); + + test('keeps everything when count is at or below the limit', async () => { + const dir = backupDir(client); + fs.mkdirSync(dir, {recursive: true}); + for (let i = 1; i <= 3; i++) fs.writeFileSync(path.join(dir, `2026-01-0${i}__mig__t.json`), '[]'); + + const deleted = await pruneOldBackups(client, 5); + expect(deleted).toEqual([]); + expect(fs.readdirSync(dir)).toHaveLength(3); + }); + + test('deletes the oldest files when count exceeds the limit', async () => { + const dir = backupDir(client); + fs.mkdirSync(dir, {recursive: true}); + const names = [ + '2026-01-01__mig__t.json', + '2026-01-02__mig__t.json', + '2026-01-03__mig__t.json', + '2026-01-04__mig__t.json', + '2026-01-05__mig__t.json' + ]; + for (const n of names) fs.writeFileSync(path.join(dir, n), '[]'); + + const deleted = await pruneOldBackups(client, 2); + + expect(deleted.sort()).toEqual([ + '2026-01-01__mig__t.json', + '2026-01-02__mig__t.json', + '2026-01-03__mig__t.json' + ]); + expect(fs.readdirSync(dir).sort()).toEqual([ + '2026-01-04__mig__t.json', + '2026-01-05__mig__t.json' + ]); + }); + + test('ignores non-JSON files when counting/pruning', async () => { + const dir = backupDir(client); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync(path.join(dir, '2026-01-01__mig__t.json'), '[]'); + fs.writeFileSync(path.join(dir, 'README.txt'), 'do not touch'); + + const deleted = await pruneOldBackups(client, 0); + expect(deleted).toEqual(['2026-01-01__mig__t.json']); + expect(fs.readdirSync(dir).sort()).toEqual(['README.txt']); + }); + + test('does not delete files in the protected set even when they would otherwise be pruned', async () => { + const dir = backupDir(client); + fs.mkdirSync(dir, {recursive: true}); + const names = [ + '2026-01-01__mig__t.json', + '2026-01-02__mig__t.json', + '2026-01-03__mig__t.json', + '2026-01-04__mig__t.json', + '2026-01-05__mig__t.json' + ]; + for (const n of names) fs.writeFileSync(path.join(dir, n), '[]'); + + const protect = new Set(['2026-01-01__mig__t.json', '2026-01-02__mig__t.json']); + const deleted = await pruneOldBackups(client, 2, protect); + + expect(deleted).toEqual(['2026-01-03__mig__t.json']); + expect(fs.readdirSync(dir).sort()).toEqual([ + '2026-01-01__mig__t.json', + '2026-01-02__mig__t.json', + '2026-01-04__mig__t.json', + '2026-01-05__mig__t.json' + ]); + }); +}); \ No newline at end of file diff --git a/tests/migrations/economy_Shop__V1.test.js b/tests/migrations/economy_Shop__V1.test.js new file mode 100644 index 00000000..6c928236 --- /dev/null +++ b/tests/migrations/economy_Shop__V1.test.js @@ -0,0 +1,131 @@ +const path = require('path'); +const {Sequelize} = require('sequelize'); + +const migration = require(path.join('..', '..', 'modules', 'economy-system', 'migrations', 'economy_Shop__V1.js')); + +function makeSequelize() { + return new Sequelize({dialect: 'sqlite', storage: ':memory:', logging: false}); +} + +describe('economy_Shop__V1 migration', () => { + test('pre-V1 schema (name as PK, no id column): table is rebuilt with id PK and existing rows survive', async () => { + const sequelize = makeSequelize(); + const queryInterface = sequelize.getQueryInterface(); + + await sequelize.query(`CREATE TABLE economy_shop ( + name VARCHAR(255) PRIMARY KEY, + price INTEGER, + role TEXT, + "createdAt" DATETIME, + "updatedAt" DATETIME + )`); + const now = new Date().toISOString(); + await sequelize.query( + 'INSERT INTO economy_shop (name, price, role, "createdAt", "updatedAt") VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)', + {replacements: ['sword', 100, 'role1', now, now, 'shield', 50, 'role2', now, now]} + ); + + await migration.up({ + context: { + queryInterface, + sequelize + } + }); + + const cols = await queryInterface.describeTable('economy_shop'); + expect(cols.id).toBeDefined(); + expect(cols.name).toBeDefined(); + expect(cols.price).toBeDefined(); + expect(cols.role).toBeDefined(); + + const [rows] = await sequelize.query('SELECT id, name, price, role FROM economy_shop ORDER BY name'); + expect(rows).toEqual([ + { + id: 'shield', + name: 'shield', + price: 50, + role: 'role2' + }, + { + id: 'sword', + name: 'sword', + price: 100, + role: 'role1' + } + ]); + + await sequelize.close(); + }); + + test('post-V1 schema (id already present): migration is a no-op', async () => { + const sequelize = makeSequelize(); + const queryInterface = sequelize.getQueryInterface(); + + await sequelize.query(`CREATE TABLE economy_shop ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255), + price INTEGER, + role TEXT, + "createdAt" DATETIME, + "updatedAt" DATETIME + )`); + const now = new Date().toISOString(); + await sequelize.query( + 'INSERT INTO economy_shop (id, name, price, role, "createdAt", "updatedAt") VALUES (?, ?, ?, ?, ?, ?)', + {replacements: ['custom-id', 'sword', 100, 'role1', now, now]} + ); + + await migration.up({ + context: { + queryInterface, + sequelize + } + }); + + const [rows] = await sequelize.query('SELECT id, name FROM economy_shop'); + expect(rows).toEqual([{ + id: 'custom-id', + name: 'sword' + }]); + + await sequelize.close(); + }); + + test('idempotent: running twice on a pre-V1 schema rebuilds once, second run is a no-op', async () => { + const sequelize = makeSequelize(); + const queryInterface = sequelize.getQueryInterface(); + + await sequelize.query(`CREATE TABLE economy_shop ( + name VARCHAR(255) PRIMARY KEY, + price INTEGER, + role TEXT, + "createdAt" DATETIME, + "updatedAt" DATETIME + )`); + await sequelize.query( + 'INSERT INTO economy_shop (name, price, role) VALUES (?, ?, ?)', + {replacements: ['sword', 100, 'role1']} + ); + + await migration.up({ + context: { + queryInterface, + sequelize + } + }); + await migration.up({ + context: { + queryInterface, + sequelize + } + }); + + const [rows] = await sequelize.query('SELECT id, name FROM economy_shop'); + expect(rows).toEqual([{ + id: 'sword', + name: 'sword' + }]); + + await sequelize.close(); + }); +}); \ No newline at end of file diff --git a/tests/migrations/levels_User__V1.test.js b/tests/migrations/levels_User__V1.test.js new file mode 100644 index 00000000..7e25dc31 --- /dev/null +++ b/tests/migrations/levels_User__V1.test.js @@ -0,0 +1,150 @@ +const path = require('path'); +const { + Sequelize, + DataTypes +} = require('sequelize'); + +const migration = require(path.join('..', '..', 'modules', 'levels', 'migrations', 'levels_User__V1.js')); + +function makeSequelize() { + return new Sequelize({dialect: 'sqlite', storage: ':memory:', logging: false}); +} + +async function createLegacyLevelsTable(sequelize) { + const queryInterface = sequelize.getQueryInterface(); + await queryInterface.createTable('levels_users', { + userID: { + type: DataTypes.STRING, + primaryKey: true + }, + xp: DataTypes.INTEGER, + messages: DataTypes.INTEGER, + level: { + type: DataTypes.INTEGER, + defaultValue: 1 + }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }); +} + +describe('levels_User__V1 migration', () => { + test('up() adds the three daily-stats columns', async () => { + const sequelize = makeSequelize(); + await createLegacyLevelsTable(sequelize); + + await sequelize.query( + 'INSERT INTO levels_users (userID, xp, messages, level, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)', + {replacements: ['123', 500, 10, 5, new Date().toISOString(), new Date().toISOString()]} + ); + + const queryInterface = sequelize.getQueryInterface(); + await migration.up({ + context: { + queryInterface, + sequelize + } + }); + + const cols = await queryInterface.describeTable('levels_users'); + expect(cols.dailyMessages).toBeDefined(); + expect(cols.dailyVoiceSeconds).toBeDefined(); + expect(cols.dailyResetDate).toBeDefined(); + + const [rows] = await sequelize.query('SELECT * FROM levels_users WHERE userID = ?', {replacements: ['123']}); + expect(rows[0].xp).toBe(500); + expect(rows[0].messages).toBe(10); + expect(rows[0].level).toBe(5); + expect(rows[0].dailyMessages).toBe(0); + expect(rows[0].dailyVoiceSeconds).toBe(0); + expect(rows[0].dailyResetDate).toBeNull(); + + await sequelize.close(); + }); + + test('up() is idempotent — re-running it on an already-migrated table is a no-op', async () => { + const sequelize = makeSequelize(); + await createLegacyLevelsTable(sequelize); + + const queryInterface = sequelize.getQueryInterface(); + await migration.up({ + context: { + queryInterface, + sequelize + } + }); + await migration.up({ + context: { + queryInterface, + sequelize + } + }); + + const cols = await queryInterface.describeTable('levels_users'); + expect(cols.dailyMessages).toBeDefined(); + + await sequelize.close(); + }); + + test('down() removes the three daily-stats columns', async () => { + const sequelize = makeSequelize(); + await createLegacyLevelsTable(sequelize); + + const queryInterface = sequelize.getQueryInterface(); + await migration.up({ + context: { + queryInterface, + sequelize + } + }); + await migration.down({ + context: { + queryInterface, + sequelize + } + }); + + const cols = await queryInterface.describeTable('levels_users'); + expect(cols.dailyMessages).toBeUndefined(); + expect(cols.dailyVoiceSeconds).toBeUndefined(); + expect(cols.dailyResetDate).toBeUndefined(); + + await sequelize.close(); + }); + + test('preserves existing row data through up()', async () => { + const sequelize = makeSequelize(); + await createLegacyLevelsTable(sequelize); + + await sequelize.query( + 'INSERT INTO levels_users (userID, xp, messages, level) VALUES (?, ?, ?, ?), (?, ?, ?, ?)', + {replacements: ['u1', 1000, 50, 7, 'u2', 2000, 100, 14]} + ); + + const queryInterface = sequelize.getQueryInterface(); + await migration.up({ + context: { + queryInterface, + sequelize + } + }); + + const [rows] = await sequelize.query('SELECT userID, xp, messages, level FROM levels_users ORDER BY userID'); + expect(rows).toEqual([ + { + userID: 'u1', + xp: 1000, + messages: 50, + level: 7 + }, + { + userID: 'u2', + xp: 2000, + messages: 100, + level: 14 + } + ]); + + await sequelize.close(); + }); +}); \ No newline at end of file diff --git a/tests/migrations/runMigrations.test.js b/tests/migrations/runMigrations.test.js new file mode 100644 index 00000000..13ab1a24 --- /dev/null +++ b/tests/migrations/runMigrations.test.js @@ -0,0 +1,355 @@ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { + Sequelize, + DataTypes, + Model +} = require('sequelize'); +const { + migrationFileNames, + tablePrefixesFromNames, + loadMigrationFile, + buildUmzug, + runAllMigrations +} = require('../../src/functions/migrations/runMigrations'); + +describe('migration filename helpers', () => { + test('migrationFileNames strips .js extensions', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-test-')); + fs.writeFileSync(path.join(dir, 'foo_Bar__V1.js'), ''); + fs.writeFileSync(path.join(dir, 'foo_Bar__V2.js'), ''); + fs.writeFileSync(path.join(dir, 'notajsfile.txt'), ''); + + expect(migrationFileNames(dir).sort()).toEqual(['foo_Bar__V1', 'foo_Bar__V2']); + + fs.rmSync(dir, { + recursive: true, + force: true + }); + }); + + test('tablePrefixesFromNames extracts the part before the last __', () => { + expect(tablePrefixesFromNames(['foo_Bar__V1', 'foo_Bar__V2', 'foo_Baz__V1']).sort()) + .toEqual(['foo_Bar', 'foo_Baz']); + }); + + test('tablePrefixesFromNames ignores names without a separator', () => { + expect(tablePrefixesFromNames(['legacyname'])).toEqual([]); + }); +}); + +function makeMarkerModel() { + const sequelize = new Sequelize({dialect: 'sqlite', storage: ':memory:', logging: false}); + + class DatabaseSchemeVersion extends Model { + } + + DatabaseSchemeVersion.init({ + model: { + type: DataTypes.STRING, + primaryKey: true + }, + version: DataTypes.STRING + }, { + sequelize, + tableName: 'system_DatabaseSchemeVersion', + timestamps: true + }); + return { + DatabaseSchemeVersion, + sequelize + }; +} + +function noop() { +} + +function fakeClient(DatabaseSchemeVersion) { + return { + models: {DatabaseSchemeVersion}, + logger: { + info: noop, + warn: noop, + error: noop, + debug: noop + } + }; +} + +describe('loadMigrationFile', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-load-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { + recursive: true, + force: true + }); + }); + + test('wraps require() errors with the offending file path', async () => { + const file = path.join(tmpDir, 'broken__V1.js'); + fs.writeFileSync(file, 'this is not valid javascript {{{'); + await expect(loadMigrationFile(file)).rejects.toThrow(file); + }); +}); + +describe('migration shutdown hooks via buildUmzug', () => { + + /* + * Regression: the old inline migrations called `migrationStart()` / `migrationEnd()` + * to defer SIGINT/SIGTERM. Stripping those calls left the new runner without any + * shutdown protection. The runner now exposes `onMigrationStart` / `onMigrationEnd` + * callbacks via its options arg; main.js wires them to client._migrationCount + * increment/decrement. Verify the contract: callbacks always fire as a pair, + * even when the migration itself throws. + */ + const realMigrationsDir = path.join(__dirname, '..', '..', 'modules', 'levels', 'migrations'); + + function pushStart(events) { + return () => events.push('start'); + } + + function pushEnd(events) { + return () => events.push('end'); + } + + test('hooks fire as a start/end pair around a successful umzug.up()', async () => { + const { + DatabaseSchemeVersion, + sequelize + } = makeMarkerModel(); + await sequelize.sync(); + const queryInterface = sequelize.getQueryInterface(); + await queryInterface.createTable('levels_users', { + userID: { + type: DataTypes.STRING, + primaryKey: true + }, + xp: DataTypes.INTEGER + }); + + const events = []; + const onStart = pushStart(events); + const onEnd = pushEnd(events); + const umzug = buildUmzug(fakeClient(DatabaseSchemeVersion), realMigrationsDir); + + onStart(); + try { + await umzug.up(); + } finally { + onEnd(); + } + + expect(events).toEqual(['start', 'end']); + await sequelize.close(); + }); + + test('try/finally pattern ensures end fires even when up() throws', async () => { + const { + DatabaseSchemeVersion, + sequelize + } = makeMarkerModel(); + await sequelize.sync(); + // No levels_users table created — addColumn throws, transaction rolls back. + + const events = []; + const onStart = pushStart(events); + const onEnd = pushEnd(events); + const umzug = buildUmzug(fakeClient(DatabaseSchemeVersion), realMigrationsDir); + + onStart(); + try { + await expect(umzug.up()).rejects.toThrow(); + } finally { + onEnd(); + } + + expect(events).toEqual(['start', 'end']); + await sequelize.close(); + }); +}); + +describe('runAllMigrations guard', () => { + + /* + * Regression: prior to the guard, runAllMigrations threw an opaque + * `TypeError: Cannot read properties of undefined (reading 'DatabaseSchemeVersion')` + * when called before `client.models` was populated in main.js boot sequence. + */ + test('throws a descriptive error when client is missing', async () => { + await expect(runAllMigrations(null)).rejects.toThrow(/DatabaseSchemeVersion is not available/); + }); + + test('throws a descriptive error when client.models is undefined', async () => { + await expect(runAllMigrations({})).rejects.toThrow(/DatabaseSchemeVersion is not available/); + }); + + test('throws a descriptive error when DatabaseSchemeVersion model is missing', async () => { + await expect(runAllMigrations({models: {}})).rejects.toThrow(/DatabaseSchemeVersion is not available/); + }); +}); + +describe('Umzug + DatabaseSchemeVersionStorage end-to-end against the real levels V1 file', () => { + const realMigrationsDir = path.join(__dirname, '..', '..', 'modules', 'levels', 'migrations'); + + test('legacy marker row makes Umzug treat V1 as already applied', async () => { + const { + DatabaseSchemeVersion, + sequelize + } = makeMarkerModel(); + await sequelize.sync(); + await DatabaseSchemeVersion.create({ + model: 'levels_User', + version: 'V1' + }); + + const queryInterface = sequelize.getQueryInterface(); + await queryInterface.createTable('levels_users', { + userID: { + type: DataTypes.STRING, + primaryKey: true + }, + xp: DataTypes.INTEGER + }); + + const umzug = buildUmzug(fakeClient(DatabaseSchemeVersion), realMigrationsDir); + expect(await umzug.pending()).toEqual([]); + + await sequelize.close(); + }); + + test('no marker, old-schema table: migration runs and adds the daily columns (bot-1364 regression)', async () => { + const { + DatabaseSchemeVersion, + sequelize + } = makeMarkerModel(); + await sequelize.sync(); + + const queryInterface = sequelize.getQueryInterface(); + await queryInterface.createTable('levels_users', { + userID: { + type: DataTypes.STRING, + primaryKey: true + }, + xp: DataTypes.INTEGER, + messages: DataTypes.INTEGER, + level: DataTypes.INTEGER + }); + await sequelize.query( + 'INSERT INTO levels_users (userID, xp, messages, level) VALUES (?, ?, ?, ?)', + {replacements: ['existing-user', 12345, 678, 90]} + ); + + const colsBefore = await queryInterface.describeTable('levels_users'); + expect(colsBefore.dailyMessages).toBeUndefined(); + expect(colsBefore.dailyVoiceSeconds).toBeUndefined(); + expect(colsBefore.dailyResetDate).toBeUndefined(); + + const umzug = buildUmzug(fakeClient(DatabaseSchemeVersion), realMigrationsDir); + expect((await umzug.pending()).map(p => p.name)).toEqual(['levels_User__V1']); + + await umzug.up(); + + const colsAfter = await queryInterface.describeTable('levels_users'); + expect(colsAfter.dailyMessages).toBeDefined(); + expect(colsAfter.dailyVoiceSeconds).toBeDefined(); + expect(colsAfter.dailyResetDate).toBeDefined(); + + const [rows] = await sequelize.query('SELECT * FROM levels_users WHERE userID = ?', {replacements: ['existing-user']}); + expect(rows[0].xp).toBe(12345); + expect(rows[0].messages).toBe(678); + expect(rows[0].level).toBe(90); + expect(rows[0].dailyMessages).toBe(0); + expect(rows[0].dailyVoiceSeconds).toBe(0); + expect(rows[0].dailyResetDate).toBeNull(); + + const marker = await DatabaseSchemeVersion.findOne({where: {model: 'levels_User__V1'}}); + expect(marker).not.toBeNull(); + + await sequelize.close(); + }); + + test('takes a JSON backup of declared tables before running the migration', async () => { + const { + DatabaseSchemeVersion, + sequelize + } = makeMarkerModel(); + await sequelize.sync(); + + const queryInterface = sequelize.getQueryInterface(); + await queryInterface.createTable('levels_users', { + userID: { + type: DataTypes.STRING, + primaryKey: true + }, + xp: DataTypes.INTEGER, + messages: DataTypes.INTEGER, + level: DataTypes.INTEGER + }); + await sequelize.query( + 'INSERT INTO levels_users (userID, xp, messages, level) VALUES (?, ?, ?, ?)', + {replacements: ['backup-user', 999, 50, 5]} + ); + + const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-flow-')); + const client = fakeClient(DatabaseSchemeVersion); + client.dataDir = tmpDataDir; + + const umzug = buildUmzug(client, realMigrationsDir); + await umzug.up(); + + const backupsDir = path.join(tmpDataDir, 'migration-backups'); + const files = fs.readdirSync(backupsDir); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/__levels_User__V1__levels_users\.json$/u); + const snapshot = JSON.parse(fs.readFileSync(path.join(backupsDir, files[0]), 'utf8')); + expect(snapshot).toEqual([{ + userID: 'backup-user', + xp: 999, + messages: 50, + level: 5 + }]); + + fs.rmSync(tmpDataDir, { + recursive: true, + force: true + }); + await sequelize.close(); + }); + + test('no marker, table already at current schema (truly fresh install): migration runs as a no-op', async () => { + const { + DatabaseSchemeVersion, + sequelize + } = makeMarkerModel(); + await sequelize.sync(); + + const queryInterface = sequelize.getQueryInterface(); + await queryInterface.createTable('levels_users', { + userID: { + type: DataTypes.STRING, + primaryKey: true + }, + xp: DataTypes.INTEGER, + messages: DataTypes.INTEGER, + level: DataTypes.INTEGER, + dailyMessages: DataTypes.INTEGER, + dailyVoiceSeconds: DataTypes.INTEGER, + dailyResetDate: DataTypes.STRING + }); + + const umzug = buildUmzug(fakeClient(DatabaseSchemeVersion), realMigrationsDir); + await umzug.up(); + + const marker = await DatabaseSchemeVersion.findOne({where: {model: 'levels_User__V1'}}); + expect(marker).not.toBeNull(); + expect(await umzug.pending()).toEqual([]); + + await sequelize.close(); + }); +}); \ No newline at end of file diff --git a/tests/nicknames/guildMemberUpdate.test.js b/tests/nicknames/guildMemberUpdate.test.js new file mode 100644 index 00000000..323312e5 --- /dev/null +++ b/tests/nicknames/guildMemberUpdate.test.js @@ -0,0 +1,143 @@ +/* + * Tests for the nicknames guildMemberUpdate handler. + * + * It reacts to role or nickname changes for the configured guild (after + * botReady, skipping the guild owner). When the nickname changed to something + * other than what the manager last rendered, the external edit is persisted as + * the new base. In all change cases the member is re-attached and an update is + * requested. + */ +const mockPersist = jest.fn().mockResolvedValue(); +jest.mock('../../modules/nicknames/persistExternalEditAsBase', () => ({ + persistExternalEditAsBase: (...a) => mockPersist(...a) +})); + +const handler = require('../../modules/nicknames/events/guildMemberUpdate'); + +function makeClient({ + ready = true, + lastRendered = null + } = {}) { + return { + botReadyAt: ready ? Date.now() : undefined, + guild: {id: 'g1'}, + nicknameManager: { + getLastRendered: jest.fn(() => lastRendered), + attachMember: jest.fn(), + requestUpdate: jest.fn() + } + }; +} + +function makeMember({ + id = 'm1', + guildID = 'g1', + ownerId = 'owner', + nickname = null, + roleIds = [] + } = {}) { + return { + id, + nickname, + guild: { + id: guildID, + ownerId + }, + roles: {cache: {keys: () => roleIds[Symbol.iterator]()}} + }; +} + +beforeEach(() => mockPersist.mockClear()); + +test('ignores updates before botReady', async () => { + const client = makeClient({ready: false}); + await handler.run(client, makeMember(), makeMember({nickname: 'New'})); + expect(client.nicknameManager.requestUpdate).not.toHaveBeenCalled(); +}); + +test('ignores updates from a different guild', async () => { + const client = makeClient(); + await handler.run(client, makeMember({guildID: 'other'}), makeMember({ + guildID: 'other', + nickname: 'X' + })); + expect(client.nicknameManager.requestUpdate).not.toHaveBeenCalled(); +}); + +test('ignores the guild owner', async () => { + const client = makeClient(); + const oldM = makeMember({ + id: 'owner', + ownerId: 'owner' + }); + const newM = makeMember({ + id: 'owner', + ownerId: 'owner', + nickname: 'X' + }); + await handler.run(client, oldM, newM); + expect(client.nicknameManager.requestUpdate).not.toHaveBeenCalled(); +}); + +test('does nothing when neither roles nor nickname changed', async () => { + const client = makeClient(); + const oldM = makeMember({ + roleIds: ['r1'], + nickname: 'Same' + }); + const newM = makeMember({ + roleIds: ['r1'], + nickname: 'Same' + }); + await handler.run(client, oldM, newM); + expect(client.nicknameManager.attachMember).not.toHaveBeenCalled(); +}); + +test('persists an external nickname edit that differs from the last render', async () => { + const client = makeClient({lastRendered: '[Bot] Alice'}); + const oldM = makeMember({nickname: 'Alice'}); + const newM = makeMember({nickname: 'Bob'}); // user manually changed it + await handler.run(client, oldM, newM); + expect(mockPersist).toHaveBeenCalledWith(client, newM); + expect(client.nicknameManager.attachMember).toHaveBeenCalledWith(newM); + expect(client.nicknameManager.requestUpdate).toHaveBeenCalledWith('m1'); +}); + +test('does not persist when the nickname matches the manager last render', async () => { + const client = makeClient({lastRendered: 'Rendered'}); + const oldM = makeMember({nickname: 'Old'}); + const newM = makeMember({nickname: 'Rendered'}); // the bot itself set it + await handler.run(client, oldM, newM); + expect(mockPersist).not.toHaveBeenCalled(); + expect(client.nicknameManager.requestUpdate).toHaveBeenCalledWith('m1'); +}); + +test('reacts to a role change even when the nickname is unchanged', async () => { + const client = makeClient(); + const oldM = makeMember({ + roleIds: ['r1'], + nickname: 'Same' + }); + const newM = makeMember({ + roleIds: ['r1', 'r2'], + nickname: 'Same' + }); + await handler.run(client, oldM, newM); + expect(mockPersist).not.toHaveBeenCalled(); // nick didn't change + expect(client.nicknameManager.attachMember).toHaveBeenCalledWith(newM); + expect(client.nicknameManager.requestUpdate).toHaveBeenCalled(); +}); + +test('detects role removal (size shrink) as a change', async () => { + const client = makeClient(); + const oldM = makeMember({ + roleIds: ['r1', 'r2'], + nickname: 'Same' + }); + const newM = makeMember({ + roleIds: ['r1'], + nickname: 'Same' + }); + await handler.run(client, oldM, newM); + expect(client.nicknameManager.requestUpdate).toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/nicknames/manager.edgeCases.test.js b/tests/nicknames/manager.edgeCases.test.js new file mode 100644 index 00000000..8fc96b03 --- /dev/null +++ b/tests/nicknames/manager.edgeCases.test.js @@ -0,0 +1,657 @@ +/* + * Edge-case unit tests for NicknameManager that complement the existing + * render / flush / providers / lifecycle suites. Focus areas: + * - pure helpers: stripDecorations, deriveBaseFromNickname, collectContributions + * - contribution normalization in set() / pollProviders() + * - global transform registration + module-enabled filtering + * - guard branches in attachMember / handleGuildMemberAdd / handleGuildMemberRemove + * - code-point-aware 32-char truncation + */ + +const EventEmitter = require('events'); +const NicknameManager = require('../../src/functions/nicknameManager'); + +/** + * Minimal client stub. + * @param {Object} [over] + * @returns {Object} + */ +function makeClient(over = {}) { + const client = new EventEmitter(); + client.modules = {}; + client.botReadyAt = new Date(); + client.guild = { + id: 'g1', + members: {cache: new Map()} + }; + client.logger = { + warn: jest.fn(), + debug: jest.fn() + }; + return Object.assign(client, over); +} + +/** + * GuildMember-shaped stub. + * @param {string} id + * @param {string} displayName + * @param {string|null} [nickname] + * @param {string} [guildId] + * @returns {Object} + */ +function makeMember(id, displayName, nickname = null, guildId = 'g1') { + return { + id, + nickname, + user: {displayName}, + guild: {id: guildId}, + setNickname: jest.fn().mockResolvedValue(null), + partial: false + }; +} + +describe('set() normalization', () => { + test('defaults priority to 0 and exclusive to false', () => { + const m = new NicknameManager(makeClient()); + m.set('1', 'src', { + position: 'suffix', + value: ' X' + }); + const c = m.members.get('1').contributions.get('src'); + expect(c.priority).toBe(0); + expect(c.exclusive).toBe(false); + expect(c.source).toBe('src'); + }); + + test('preserves explicit priority/exclusive and marks state applyQueued', () => { + const m = new NicknameManager(makeClient()); + m.set('1', 'src', { + position: 'prefix', + value: 'A', + priority: 7, + exclusive: true + }); + const state = m.members.get('1'); + const c = state.contributions.get('src'); + expect(c.priority).toBe(7); + expect(c.exclusive).toBe(true); + expect(state.applyQueued).toBe(true); + }); + + test('a second set for the same source overwrites the prior contribution', () => { + const m = new NicknameManager(makeClient()); + m.set('1', 'src', { + position: 'suffix', + value: ' A' + }); + m.set('1', 'src', { + position: 'suffix', + value: ' B' + }); + expect(m.members.get('1').contributions.size).toBe(1); + expect(m.members.get('1').contributions.get('src').value).toBe(' B'); + }); + + test('does not schedule a flush when the member has no live ref', () => { + const m = new NicknameManager(makeClient()); + const spy = jest.spyOn(m, 'scheduleFlush'); + m.set('1', 'src', { + position: 'suffix', + value: ' X' + }); + expect(spy).not.toHaveBeenCalled(); + }); + + test('schedules a flush when the member already has a live ref', () => { + const m = new NicknameManager(makeClient()); + m.attachMember(makeMember('1', 'Alice')); + const spy = jest.spyOn(m, 'scheduleFlush'); + m.set('1', 'src', { + position: 'suffix', + value: ' X' + }); + expect(spy).toHaveBeenCalledWith('1'); + }); +}); + +describe('clear()', () => { + test('clearing an unknown member is a no-op (no state created)', () => { + const m = new NicknameManager(makeClient()); + expect(() => m.clear('ghost', 'src')).not.toThrow(); + expect(m.members.has('ghost')).toBe(false); + }); + + test('clear removes a contribution and marks applyQueued', () => { + const m = new NicknameManager(makeClient()); + m.set('1', 'src', { + position: 'suffix', + value: ' X' + }); + m.members.get('1').applyQueued = false; + m.clear('1', 'src'); + expect(m.members.get('1').contributions.has('src')).toBe(false); + expect(m.members.get('1').applyQueued).toBe(true); + }); +}); + +describe('clearAllForSource()', () => { + test('removes a source from every member but leaves other sources intact', () => { + const m = new NicknameManager(makeClient()); + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥1' + }); + m.set('1', 'afk', { + position: 'prefix', + value: '[AFK] ' + }); + m.set('2', 'streak', { + position: 'suffix', + value: ' 🔥2' + }); + m.clearAllForSource('streak'); + expect(m.members.get('1').contributions.has('streak')).toBe(false); + expect(m.members.get('1').contributions.has('afk')).toBe(true); + expect(m.members.get('2').contributions.has('streak')).toBe(false); + }); +}); + +describe('global transforms', () => { + test('registerGlobalTransform stores normalized entry with default priority 0', () => { + const m = new NicknameManager(makeClient()); + const value = (s) => s.toUpperCase(); + m.registerGlobalTransform('san', 'sanitizer', { + position: 'baseTransform', + value + }); + const g = m.globalTransforms.get('san'); + expect(g.moduleName).toBe('sanitizer'); + expect(g.position).toBe('baseTransform'); + expect(g.value).toBe(value); + expect(g.priority).toBe(0); + }); + + test('registerGlobalTransform keeps an explicit priority', () => { + const m = new NicknameManager(makeClient()); + m.registerGlobalTransform('san', 'sanitizer', { + position: 'wrap', + value: (s) => s, + priority: 9 + }); + expect(m.globalTransforms.get('san').priority).toBe(9); + }); + + test('unregisterGlobalTransform removes the entry', () => { + const m = new NicknameManager(makeClient()); + m.registerGlobalTransform('san', 'sanitizer', { + position: 'baseTransform', + value: (s) => s + }); + m.unregisterGlobalTransform('san'); + expect(m.globalTransforms.has('san')).toBe(false); + }); + + test('collectContributions excludes globals from disabled modules', () => { + const client = makeClient({modules: {sanitizer: {enabled: false}}}); + const m = new NicknameManager(client); + m.registerGlobalTransform('san', 'sanitizer', { + position: 'baseTransform', + value: (s) => s + }); + const all = m.collectContributions('1'); + expect(all.some(c => c.source === 'san')).toBe(false); + }); + + test('collectContributions includes globals from enabled modules with exclusive:false', () => { + const client = makeClient({modules: {sanitizer: {enabled: true}}}); + const m = new NicknameManager(client); + m.registerGlobalTransform('san', 'sanitizer', { + position: 'baseTransform', + value: (s) => s, + priority: 3 + }); + const all = m.collectContributions('1'); + const g = all.find(c => c.source === 'san'); + expect(g).toMatchObject({ + position: 'baseTransform', + priority: 3, + exclusive: false + }); + }); +}); + +describe('isModuleEnabled()', () => { + test('returns true for a falsy module name (core contributions)', () => { + const m = new NicknameManager(makeClient()); + expect(m.isModuleEnabled(undefined)).toBe(true); + expect(m.isModuleEnabled('')).toBe(true); + }); + + test('returns true for an unknown module', () => { + const m = new NicknameManager(makeClient()); + expect(m.isModuleEnabled('nope')).toBe(true); + }); + + test('returns false only when the module is explicitly disabled', () => { + const client = makeClient({ + modules: { + a: {enabled: false}, + b: {enabled: true}, + c: {} + } + }); + const m = new NicknameManager(client); + expect(m.isModuleEnabled('a')).toBe(false); + expect(m.isModuleEnabled('b')).toBe(true); + // enabled is undefined (not === false) -> treated as enabled + expect(m.isModuleEnabled('c')).toBe(true); + }); +}); + +describe('stripDecorations()', () => { + test('returns input unchanged when there are no decorations', () => { + const m = new NicknameManager(makeClient()); + expect(m.stripDecorations('Alice', [])).toBe('Alice'); + expect(m.stripDecorations('Alice', null)).toBe('Alice'); + }); + + test('strips a literal prefix and suffix', () => { + const m = new NicknameManager(makeClient()); + const out = m.stripDecorations('[VIP] Alice!', [ + { + position: 'prefix', + value: '[VIP] ' + }, + { + position: 'suffix', + value: '!' + } + ]); + expect(out).toBe('Alice'); + }); + + test('reverses a wrap via the sentinel trick', () => { + const m = new NicknameManager(makeClient()); + const out = m.stripDecorations('[AFK] Alice', [ + { + position: 'wrap', + value: (s) => '[AFK] ' + s, + priority: 1 + } + ]); + expect(out).toBe('Alice'); + }); + + test('skips a non-reversible wrap (sentinel not present in output)', () => { + const m = new NicknameManager(makeClient()); + const out = m.stripDecorations('Alice', [ + { + position: 'wrap', + value: () => 'constant', + priority: 1 + } + ]); + expect(out).toBe('Alice'); + }); + + test('skips a wrap whose value is not a function', () => { + const m = new NicknameManager(makeClient()); + const out = m.stripDecorations('xAlice', [ + { + position: 'wrap', + value: 'x', + priority: 1 + } + ]); + // Non-function wrap is ignored, leaving the string untouched. + expect(out).toBe('xAlice'); + }); + + test('strips a regex-matched suffix whose literal value varies', () => { + const m = new NicknameManager(makeClient()); + const out = m.stripDecorations('Alice 🔥42', [ + { + position: 'suffix', + value: ' 🔥3', + match: / 🔥\d+/ + } + ]); + expect(out).toBe('Alice'); + }); + + test('strips a regex-matched prefix', () => { + const m = new NicknameManager(makeClient()); + const out = m.stripDecorations('##Alice', [ + { + position: 'prefix', + value: '#', + match: /#+/ + } + ]); + expect(out).toBe('Alice'); + }); + + test('loops until stable so stacked affixes from the same set strip cleanly', () => { + const m = new NicknameManager(makeClient()); + const out = m.stripDecorations('--Alice', [ + { + position: 'prefix', + value: '-' + } + ]); + expect(out).toBe('Alice'); + }); + + test('a regex match of zero length does not slice', () => { + const m = new NicknameManager(makeClient()); + const out = m.stripDecorations('Alice', [ + { + position: 'prefix', + value: '', + match: /x*/ + } + ]); + expect(out).toBe('Alice'); + }); +}); + +describe('deriveBaseFromNickname()', () => { + test('uses lastDecorations over current decorations when present', () => { + const m = new NicknameManager(makeClient()); + const member = makeMember('1', 'Alice', '[OLD] Alice'); + const state = { + lastDecorations: [{ + position: 'prefix', + value: '[OLD] ' + }] + }; + const out = m.deriveBaseFromNickname(member, state, [{ + position: 'prefix', + value: '[NEW] ' + }]); + expect(out).toBe('Alice'); + }); + + test('falls back to current decorations on cold start (no lastDecorations)', () => { + const m = new NicknameManager(makeClient()); + const member = makeMember('1', 'Alice', '[VIP] Alice'); + const out = m.deriveBaseFromNickname(member, {lastDecorations: null}, [{ + position: 'prefix', + value: '[VIP] ' + }]); + expect(out).toBe('Alice'); + }); + + test('returns the current nickname when there are no patterns to strip', () => { + const m = new NicknameManager(makeClient()); + const member = makeMember('1', 'Alice', 'Manual Name'); + const out = m.deriveBaseFromNickname(member, {lastDecorations: null}, []); + expect(out).toBe('Manual Name'); + }); + + test('uses displayName when member has no nickname and no patterns', () => { + const m = new NicknameManager(makeClient()); + const member = makeMember('1', 'Alice', null); + const out = m.deriveBaseFromNickname(member, null, []); + expect(out).toBe('Alice'); + }); + + test('falls back to displayName when stripping yields an empty residue', () => { + const m = new NicknameManager(makeClient()); + const member = makeMember('1', 'Alice', '[VIP] '); + const out = m.deriveBaseFromNickname(member, null, [{ + position: 'prefix', + value: '[VIP] ' + }]); + expect(out).toBe('Alice'); + }); +}); + +describe('getLastRendered() / getContributions()', () => { + test('getLastRendered returns null for an unknown member', () => { + const m = new NicknameManager(makeClient()); + expect(m.getLastRendered('ghost')).toBe(null); + }); + + test('getLastRendered returns the stored value', () => { + const m = new NicknameManager(makeClient()); + m.stateFor('1').lastRendered = 'Alice 🔥1'; + expect(m.getLastRendered('1')).toBe('Alice 🔥1'); + }); + + test('getContributions returns [] for an unknown member', () => { + const m = new NicknameManager(makeClient()); + expect(m.getContributions('ghost')).toEqual([]); + }); + + test('getContributions returns the live contribution list', () => { + const m = new NicknameManager(makeClient()); + m.set('1', 'src', { + position: 'suffix', + value: ' X' + }); + expect(m.getContributions('1')).toHaveLength(1); + expect(m.getContributions('1')[0].source).toBe('src'); + }); +}); + +describe('stateFor()', () => { + test('creates an empty state on first access and reuses it', () => { + const m = new NicknameManager(makeClient()); + const a = m.stateFor('1'); + const b = m.stateFor('1'); + expect(a).toBe(b); + expect(a.contributions.size).toBe(0); + expect(a.lastRendered).toBe(null); + expect(a.applyQueued).toBe(false); + }); +}); + +describe('attachMember()', () => { + test('stores the member ref and seeds state', () => { + const m = new NicknameManager(makeClient()); + const member = makeMember('1', 'Alice'); + m.attachMember(member); + expect(m.memberRefs.get('1')).toBe(member); + expect(m.members.has('1')).toBe(true); + }); +}); + +describe('pollProviders() edge cases', () => { + test('a throwing provider is caught, logged, and does not abort other providers', async () => { + const client = makeClient({ + modules: { + a: {enabled: true}, + b: {enabled: true} + } + }); + const m = new NicknameManager(client); + m.registerProvider('a', 'a', async () => { + throw new Error('boom'); + }); + m.registerProvider('b', 'b', async () => ({ + source: 'b', + position: 'suffix', + value: ' B' + })); + const member = makeMember('1', 'Alice'); + await m.pollProviders(member); + expect(client.logger.warn).toHaveBeenCalled(); + expect(m.getContributions('1').some(c => c.source === 'b')).toBe(true); + }); + + test('a provider returning undefined clears its prior contribution', async () => { + const client = makeClient({modules: {a: {enabled: true}}}); + const m = new NicknameManager(client); + let value = { + source: 'a', + position: 'suffix', + value: ' A' + }; + m.registerProvider('a', 'a', async () => value); + const member = makeMember('1', 'Alice'); + await m.pollProviders(member); + expect(m.getContributions('1')).toHaveLength(1); + value = undefined; + await m.pollProviders(member); + expect(m.getContributions('1')).toHaveLength(0); + }); + + test('a provider can shrink its sub-source contribution set between polls', async () => { + const client = makeClient({modules: {a: {enabled: true}}}); + const m = new NicknameManager(client); + let list = [ + { + source: 'a:one', + position: 'prefix', + value: '1' + }, + { + source: 'a:two', + position: 'prefix', + value: '2' + } + ]; + m.registerProvider('a', 'a', async () => list); + const member = makeMember('1', 'Alice'); + await m.pollProviders(member); + expect(m.getContributions('1')).toHaveLength(2); + // Provider now returns only one of its prior sub-sources. + list = [{ + source: 'a:one', + position: 'prefix', + value: '1' + }]; + await m.pollProviders(member); + const sources = m.getContributions('1').map(c => c.source).sort(); + expect(sources).toEqual(['a:one']); + }); + + test('disabled provider modules have their prior contribution dropped', async () => { + const client = makeClient({modules: {a: {enabled: true}}}); + const m = new NicknameManager(client); + m.registerProvider('a', 'a', async () => ({ + source: 'a', + position: 'suffix', + value: ' A' + })); + const member = makeMember('1', 'Alice'); + await m.pollProviders(member); + expect(m.getContributions('1')).toHaveLength(1); + client.modules.a.enabled = false; + await m.pollProviders(member); + expect(m.getContributions('1')).toHaveLength(0); + }); + + test('provider contributions are normalized (default priority/exclusive)', async () => { + const client = makeClient({modules: {a: {enabled: true}}}); + const m = new NicknameManager(client); + m.registerProvider('a', 'a', async () => ({ + source: 'a', + position: 'suffix', + value: ' A' + })); + await m.pollProviders(makeMember('1', 'Alice')); + const c = m.getContributions('1')[0]; + expect(c.priority).toBe(0); + expect(c.exclusive).toBe(false); + }); +}); + +describe('render() truncation is code-point aware', () => { + test('a surrogate-pair emoji on the 32-char boundary is not split', () => { + const m = new NicknameManager(makeClient()); + // 31 ASCII chars + one emoji (2 code units, 1 code point) = 32 code points. + const base = 'x'.repeat(31) + '😀'; + m.set('1', 'base', { + position: 'base', + value: base, + priority: 100 + }); + const member = makeMember('1', 'Alice'); + const out = m.render(member); + expect([...out]).toHaveLength(32); + // The trailing emoji is intact, not a lone surrogate. + expect(out.endsWith('😀')).toBe(true); + }); + + test('long names are truncated to 32 code points', () => { + const m = new NicknameManager(makeClient()); + m.set('1', 'base', { + position: 'base', + value: 'y'.repeat(50), + priority: 100 + }); + const out = m.render(makeMember('1', 'Alice')); + expect([...out]).toHaveLength(32); + }); +}); + +describe('handleGuildMemberAdd guards', () => { + test('ignores members of a different guild', () => { + const m = new NicknameManager(makeClient()); + const spy = jest.spyOn(m, 'attachMember'); + m.handleGuildMemberAdd(makeMember('1', 'Alice', null, 'other')); + expect(spy).not.toHaveBeenCalled(); + }); + + test('ignores when bot is not ready', () => { + const client = makeClient({botReadyAt: null}); + const m = new NicknameManager(client); + const spy = jest.spyOn(m, 'attachMember'); + m.handleGuildMemberAdd(makeMember('1', 'Alice')); + expect(spy).not.toHaveBeenCalled(); + }); + + test('attaches and requests update for a home-guild member', () => { + const m = new NicknameManager(makeClient()); + const attach = jest.spyOn(m, 'attachMember'); + const req = jest.spyOn(m, 'requestUpdate'); + m.handleGuildMemberAdd(makeMember('1', 'Alice')); + expect(attach).toHaveBeenCalled(); + expect(req).toHaveBeenCalledWith('1'); + }); +}); + +describe('handleGuildMemberRemove cross-guild guard', () => { + test('does not drop state for a member from a different guild', () => { + const m = new NicknameManager(makeClient()); + m.attachMember(makeMember('1', 'Alice')); + m.handleGuildMemberRemove({ + id: '1', + guild: {id: 'other'} + }); + expect(m.members.has('1')).toBe(true); + expect(m.memberRefs.has('1')).toBe(true); + }); + + test('drops state when guild is undefined (member.guild missing)', () => { + const m = new NicknameManager(makeClient()); + m.attachMember(makeMember('1', 'Alice')); + // No guild.id -> guard short-circuits the early return and removal happens. + m.handleGuildMemberRemove({ + id: '1', + guild: undefined + }); + expect(m.members.has('1')).toBe(false); + }); +}); + +describe('requestUpdate()', () => { + test('marks applyQueued and schedules a flush', () => { + const m = new NicknameManager(makeClient()); + const spy = jest.spyOn(m, 'scheduleFlush'); + m.requestUpdate('1'); + expect(m.stateFor('1').applyQueued).toBe(true); + expect(spy).toHaveBeenCalledWith('1'); + }); +}); + +describe('handleBotReady() with no guild', () => { + test('returns early when client.guild is missing', async () => { + const client = makeClient({guild: null}); + const m = new NicknameManager(client); + await expect(m.handleBotReady()).resolves.toBeUndefined(); + expect(m.members.size).toBe(0); + }); +}); \ No newline at end of file diff --git a/tests/nicknames/manager.flush.test.js b/tests/nicknames/manager.flush.test.js new file mode 100644 index 00000000..a7a5bca5 --- /dev/null +++ b/tests/nicknames/manager.flush.test.js @@ -0,0 +1,479 @@ +const NicknameManager = require('../../src/functions/nicknameManager'); + +/** + * Builds a NicknameManager bound to a client stub. + * @param {Object} [modules] + * @returns {NicknameManager} + */ +function makeManager(modules = {}) { + const client = { + modules, + logger: { + warn: () => { + }, + debug: () => { + } + } + }; + return new NicknameManager(client); +} + +/** + * Builds a minimal GuildMember-shaped object with a mockable setNickname. + * @param {string} id + * @param {string} displayName + * @param {string|null} [nickname] + * @param {Object} [opts] + * @returns {Object} + */ +function makeMember(id, displayName, nickname, opts = {}) { + const setNickname = opts.setNickname ?? jest.fn().mockResolvedValue(); + return { + id, + nickname: nickname ?? null, + user: {displayName}, + setNickname + }; +} + +/** + * Awaits one event loop turn so queued setImmediate callbacks can run. + * @returns {Promise} + */ +function tick() { + return new Promise(setImmediate); +} + +describe('NicknameManager flush', () => { + test('multiple set calls in same tick coalesce to one setNickname call', async () => { + const m = makeManager(); + const member = makeMember('1', 'Alice'); + m.attachMember(member); + + m.set('1', 'nicknames:base', { + position: 'base', + value: 'Alice', + priority: 100 + }); + m.set('1', 'src-a', { + position: 'suffix', + value: ' A', + priority: 1 + }); + m.set('1', 'src-b', { + position: 'suffix', + value: ' B', + priority: 2 + }); + m.requestUpdate('1'); + + await tick(); + + expect(member.setNickname).toHaveBeenCalledTimes(1); + expect(member.setNickname).toHaveBeenCalledWith('Alice B A', expect.any(String)); + }); + + test('skip setNickname when rendered === current member.nickname', async () => { + const m = makeManager(); + const member = makeMember('1', 'Alice', 'Alice 🔥3'); + m.attachMember(member); + + m.set('1', 'nicknames:base', { + position: 'base', + value: 'Alice', + priority: 100 + }); + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥3', + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).not.toHaveBeenCalled(); + expect(m.getLastRendered('1')).toBe('Alice 🔥3'); + }); + + test('skip setNickname when rendered === displayName and member.nickname is null', async () => { + const m = makeManager(); + const member = makeMember('1', 'Alice', null); + m.attachMember(member); + + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).not.toHaveBeenCalled(); + }); + + test('does not overwrite a manual nickname when no base contribution is provided', async () => { + const m = makeManager(); + const member = makeMember('1', 'Bob', 'Alice'); + m.attachMember(member); + + m.requestUpdate('1'); + await tick(); + + // No module touched this member; manager must leave the manual nickname alone. + expect(member.setNickname).not.toHaveBeenCalled(); + }); + + test('leaves manual nickname alone when only a disabled-module global transform is registered', async () => { + // Module onLoad runs even when the module is disabled, so registration alone + // is not a signal that the module wants to participate. The flush bail-out + // must filter global transforms by enabled state. + const m = makeManager({sanitizer: {enabled: false}}); + m.registerGlobalTransform('sanitizer', 'sanitizer', { + position: 'baseTransform', + value: (s) => s.toUpperCase(), + priority: 50 + }); + const member = makeMember('1', 'Bob', 'Dr. rer. nat. Albj'); + m.attachMember(member); + + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).not.toHaveBeenCalled(); + }); + + test('global transform applies to manual nickname (not displayName) when no base provider contributes', async () => { + // Setup: sanitizer (a global baseTransform) is the ONLY enabled contributor. + // The user manually set their nickname to "★Bob"; the sanitizer strips the + // leading "★". Base must default to the manual nickname so the transform + // operates on what's there, not on displayName which would clobber the edit. + const m = makeManager({sanitizer: {enabled: true}}); + m.registerGlobalTransform('sanitizer', 'sanitizer', { + position: 'baseTransform', + value: (s) => s.replace(/^[★]+/, ''), + priority: 50 + }); + const member = makeMember('1', 'Alice', '★Bob'); + m.attachMember(member); + + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).toHaveBeenCalledWith('Bob', expect.any(String)); + }); + + test('decoration without a base owner derives base from current nickname instead of clobbering it', async () => { + // The user manually set their nickname to "Bob"; activity-streak is the + // only decorating module active (no nicknames module providing a base). + // The manager must derive the base from "Bob" (not displayName "Alice") + // and apply the streak suffix on top — preserving the manual edit while + // still enforcing the always-on decoration. + const m = makeManager(); + const member = makeMember('1', 'Alice', 'Bob'); + m.attachMember(member); + + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥3', + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).toHaveBeenCalledWith('Bob 🔥3', expect.any(String)); + }); + + test('derives base from current nickname and strips matching decoration on bootstrap', async () => { + // Live nickname already includes the streak suffix the provider returns. + // First flush has no lastDecorations history, so derivation falls back + // to stripping the current decoration patterns. Result must equal the + // live nickname so no API call is made. + const m = makeManager(); + const member = makeMember('1', 'Alice', 'Bob 🔥3'); + m.attachMember(member); + + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥3', + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).not.toHaveBeenCalled(); + }); + + test('cold start: streak suffix with a different count is stripped via match regex', async () => { + // Bot was offline while streak ticked from 3 to 4. Live nickname still + // shows "Bob 🔥3"; the provider now returns " 🔥4". Without a match + // pattern, stripping " 🔥4" from "Bob 🔥3" would fail and the next + // render would produce "Bob 🔥3 🔥4". With the provider exposing + // `match: / 🔥\d+/`, the prior count is recognized and stripped, + // yielding "Bob 🔥4". + const m = makeManager(); + const member = makeMember('1', 'Alice', 'Bob 🔥3'); + m.attachMember(member); + + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥4', + match: / 🔥\d+/, + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).toHaveBeenCalledWith('Bob 🔥4', expect.any(String)); + }); + + test('only activity-streak active: streak is re-added after the user removes it manually', async () => { + // Activity-streak is the only decorating module; no nicknames module + // owns the base. The user manually edits their nickname to plain "Bob" + // (no suffix). Next flush must re-add the streak suffix on top — the + // streak is core functionality and must always be enforced. + const m = makeManager(); + const member = makeMember('1', 'Alice', 'Bob'); + m.attachMember(member); + + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥5', + match: / 🔥\d+/, + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).toHaveBeenCalledWith('Bob 🔥5', expect.any(String)); + }); + + test('manual streak-count edit is reverted to the DB value, not doubled', async () => { + // DB says streak = 2. Live nickname is "Bob 🔥2". A user manually edits + // their nickname to "Bob 🔥3" trying to display a higher streak. The + // manager must derive the base by stripping the bogus " 🔥3" via the + // provider's match regex (not the literal " 🔥2"), then re-apply the + // authoritative " 🔥2" — so the result is "Bob 🔥2", not "Bob 🔥3 🔥2". + const m = makeManager(); + const member = makeMember('1', 'Alice', 'Bob 🔥3'); + m.attachMember(member); + + // Provider returns the authoritative value with a match pattern that + // catches any " 🔥" suffix on the live nickname. + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥2', + match: / 🔥\d+/, + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).toHaveBeenCalledWith('Bob 🔥2', expect.any(String)); + }); + + test('removing a wrap strips it off the live nickname via lastDecorations', async () => { + // User was AFK; live nickname has the [AFK] wrap. AFK ends. Provider + // returns null; current decorations are now empty. lastDecorations + // still records the wrap, so the manager strips it off the live + // nickname instead of leaving the [AFK] permanently stuck. + const m = makeManager(); + const member = makeMember('1', 'Alice', '[AFK] Bob'); + m.attachMember(member); + + // Establish lastDecorations by going through a flush WITH the wrap. + m.set('1', 'afk', { + position: 'wrap', + value: (s) => '[AFK] ' + s, + priority: 500 + }); + m.requestUpdate('1'); + await tick(); + + // Now AFK ends — clear the contribution and request another flush. + m.clear('1', 'afk'); + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).toHaveBeenLastCalledWith('Bob', expect.any(String)); + }); + + test('only moderation active: untouched members are not flushed; only the muted one is wrapped', async () => { + // Moderation provider returns a wrap only for muted members. With no + // nicknames module, the manager has no opinion on un-muted members — + // it must NOT touch their nicknames. The muted user gets the wrap + // applied on top of their current (manual) nickname. + const m = makeManager({moderation: {enabled: true}}); + + m.registerProvider('mod:mute', 'moderation', async (member) => { + if (!member.isCommunicationDisabled?.()) return null; + return { + source: 'mod:mute', + position: 'wrap', + value: (s) => '[Muted] ' + s, + priority: 1000, + exclusive: true + }; + }); + + const innocent = makeMember('A', 'Alice', 'AliceCustom'); + innocent.isCommunicationDisabled = () => false; + m.attachMember(innocent); + m.requestUpdate('A'); + + const muted = makeMember('B', 'Bob', 'BobCustom'); + muted.isCommunicationDisabled = () => true; + m.attachMember(muted); + m.requestUpdate('B'); + + await tick(); + await tick(); + + expect(innocent.setNickname).not.toHaveBeenCalled(); + expect(muted.setNickname).toHaveBeenCalledWith('[Muted] BobCustom', expect.any(String)); + }); + + test('decoration value change uses lastDecorations to strip the prior pattern', async () => { + // Streak goes from " 🔥3" to " 🔥4". The first flush establishes + // lastDecorations=[" 🔥3"]. After that the streak value changes; the + // second flush must strip the OLD " 🔥3" off "Bob 🔥3" before applying + // " 🔥4", producing "Bob 🔥4" — not "Bob 🔥3 🔥4". + const m = makeManager(); + const member = makeMember('1', 'Alice', 'Bob 🔥3'); + m.attachMember(member); + + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥3', + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + // Streak ticks up. Mutate the member ref's nickname to mirror Discord. + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥4', + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).toHaveBeenCalledWith('Bob 🔥4', expect.any(String)); + }); + + test('updates lastRendered on successful setNickname', async () => { + const m = makeManager(); + const member = makeMember('1', 'Alice'); + m.attachMember(member); + + m.set('1', 'nicknames:base', { + position: 'base', + value: 'Alice', + priority: 100 + }); + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥1', + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + expect(m.getLastRendered('1')).toBe('Alice 🔥1'); + }); + + test('does not update lastRendered on setNickname failure', async () => { + const setNickname = jest.fn().mockRejectedValue(new Error('rate limit')); + const m = makeManager(); + const member = makeMember('1', 'Alice', null, {setNickname}); + m.attachMember(member); + + m.set('1', 'nicknames:base', { + position: 'base', + value: 'Alice', + priority: 100 + }); + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥1', + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + expect(setNickname).toHaveBeenCalled(); + expect(m.getLastRendered('1')).toBe(null); + }); + + test('provider-driven flush does not schedule an infinite loop', async () => { + const m = makeManager({mod: {enabled: true}}); + let pollCount = 0; + + /** + * Stable provider; we count invocations to detect a polling loop. + * @returns {Object} + */ + function provider() { + pollCount = pollCount + 1; + return { + position: 'suffix', + value: ' X', + priority: 1 + }; + } + + m.registerProvider('test', 'mod', provider); + + const member = makeMember('1', 'Alice'); + m.attachMember(member); + m.requestUpdate('1'); + await tick(); + await tick(); + await tick(); + await tick(); + + // One poll for the initial flush. Anything beyond a small handful is a loop. + expect(pollCount).toBeLessThanOrEqual(2); + }); + + test('serializes pending setNickname per member', async () => { + let resolveFirst; + const firstPromise = new Promise(r => { + resolveFirst = r; + }); + const setNickname = jest.fn() + .mockImplementationOnce(() => firstPromise) + .mockResolvedValue(); + + const m = makeManager(); + const member = makeMember('1', 'Alice', null, {setNickname}); + m.attachMember(member); + + m.set('1', 'nicknames:base', { + position: 'base', + value: 'Alice', + priority: 100 + }); + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥1', + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + // First setNickname call is in flight. Queue another change. + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥2', + priority: 1 + }); + m.requestUpdate('1'); + await tick(); + + // Second flush should be waiting on first; setNickname not yet called twice. + expect(setNickname).toHaveBeenCalledTimes(1); + + resolveFirst(); + await tick(); + await tick(); + + expect(setNickname).toHaveBeenCalledTimes(2); + expect(setNickname).toHaveBeenLastCalledWith('Alice 🔥2', expect.any(String)); + }); +}); \ No newline at end of file diff --git a/tests/nicknames/manager.lifecycle.test.js b/tests/nicknames/manager.lifecycle.test.js new file mode 100644 index 00000000..14f7fe89 --- /dev/null +++ b/tests/nicknames/manager.lifecycle.test.js @@ -0,0 +1,302 @@ +const EventEmitter = require('events'); +const NicknameManager = require('../../src/functions/nicknameManager'); + +/** + * Builds a minimal Client stub that emits lifecycle events the manager listens to. + * @returns {EventEmitter} + */ +function makeClientStub() { + const client = new EventEmitter(); + client.modules = {}; + client.logger = { + warn: () => { + }, + debug: () => { + } + }; + client.botReadyAt = new Date(); + client.guild = { + id: 'g1', + members: {cache: new Map()} + }; + return client; +} + +/** + * Builds a GuildMember-shaped stub. + * @param {string} id member id + * @param {string} displayName user.displayName + * @param {string|null} [nickname] member.nickname + * @param {string} [guildId] guild id (defaults to g1) + * @returns {Object} + */ +function makeMember(id, displayName, nickname, guildId = 'g1') { + const setNickname = jest.fn().mockResolvedValue(null); + return { + id, + nickname: nickname ?? null, + user: {displayName}, + guild: {id: guildId}, + setNickname, + partial: false + }; +} + +/** + * Returns a promise that resolves on the next microtask tick. + * @returns {Promise} + */ +function tick() { + return new Promise(setImmediate); +} + +describe('NicknameManager lifecycle', () => { + test('install is idempotent', () => { + const client = makeClientStub(); + const m = new NicknameManager(client); + m.install(); + m.install(); + expect(client.listenerCount('configReload')).toBe(1); + expect(client.listenerCount('guildMemberUpdate')).toBe(1); + }); + + test('configReload wipes per-member contributions', () => { + const client = makeClientStub(); + const m = new NicknameManager(client); + m.install(); + m.set('1', 'src', { + position: 'suffix', + value: ' X', + priority: 1 + }); + client.emit('configReload'); + expect(m.members.get('1').contributions.size).toBe(0); + expect(m.members.get('1').lastRendered).toBe(null); + }); + + test('guildMemberAdd attaches and requests update', async () => { + const client = makeClientStub(); + const m = new NicknameManager(client); + m.install(); + const member = makeMember('1', 'Alice'); + client.emit('guildMemberAdd', member); + await tick(); + // Renders to displayName, equal to current null/displayName, so no API call. + expect(member.setNickname).not.toHaveBeenCalled(); + }); + + test('guildMemberUpdate ignored when not bot ready', () => { + const client = makeClientStub(); + client.botReadyAt = null; + const m = new NicknameManager(client); + m.install(); + const oldM = makeMember('1', 'Alice', 'old'); + const newM = makeMember('1', 'Alice', 'new'); + client.emit('guildMemberUpdate', oldM, newM); + // No throw, no state change. + expect(m.members.has('1')).toBe(false); + }); + + test('guildMemberUpdate skipped for partial members', () => { + const client = makeClientStub(); + const m = new NicknameManager(client); + m.install(); + const oldM = makeMember('1', 'Alice', 'old'); + oldM.partial = true; + const newM = makeMember('1', 'Alice', 'new'); + newM.partial = true; + client.emit('guildMemberUpdate', oldM, newM); + expect(m.members.has('1')).toBe(false); + }); + + test('botReady processes every cached member, including ones with role-prefix providers', async () => { + const client = makeClientStub(); + client.modules = {nicknames: {enabled: true}}; + const m = new NicknameManager(client); + m.install(); + + // Three members: A needs prefix added; B already has it; C has no role. + const memberA = makeMember('A', 'Alice', null); + const memberB = makeMember('B', 'Bob', '[VIP] Bob'); + const memberC = makeMember('C', 'Carol', null); + client.guild.members.cache.set('A', memberA); + client.guild.members.cache.set('B', memberB); + client.guild.members.cache.set('C', memberC); + + // Only A and B have the configured role. + const roleHaver = new Set(['A', 'B']); + m.registerProvider('nicknames', 'nicknames', async (member) => { + const out = [{ + source: 'nicknames:base', + position: 'base', + value: member.user.displayName, + priority: 100 + }]; + if (roleHaver.has(member.id)) { + out.push({ + source: 'nicknames:rolePrefix', + position: 'prefix', + value: '[VIP] ', + priority: 10 + }); + } + return out; + }); + + client.emit('botReady'); + // Allow all queued setImmediate flushes to drain. + for (let i = 0; i < 5; i = i + 1) await tick(); + + expect(memberA.setNickname).toHaveBeenCalledWith('[VIP] Alice', expect.any(String)); + expect(memberB.setNickname).not.toHaveBeenCalled(); + expect(memberC.setNickname).not.toHaveBeenCalled(); + }); + + test('guildMemberUpdate ignores echo of own write', async () => { + const client = makeClientStub(); + const m = new NicknameManager(client); + m.install(); + const member = makeMember('1', 'Alice', 'Alice 🔥3'); + m.attachMember(member); + m.members.get('1').lastRendered = 'Alice 🔥3'; + + const oldM = makeMember('1', 'Alice', 'Alice'); + client.emit('guildMemberUpdate', oldM, member); + await tick(); + expect(member.setNickname).not.toHaveBeenCalled(); + }); + + test('guildMemberRemove drops state and member ref so they do not leak', () => { + const client = makeClientStub(); + const m = new NicknameManager(client); + m.install(); + const member = makeMember('1', 'Alice'); + member.guild = {id: 'g1'}; + m.attachMember(member); + m.set('1', 'src', {position: 'suffix', value: ' X', priority: 1}); + + client.emit('guildMemberRemove', member); + + expect(m.members.has('1')).toBe(false); + expect(m.memberRefs?.has('1')).toBe(false); + }); + + test('bootstrap hook can poll providers itself to see active contributions', async () => { + // The bootstrap hook is responsible for making any provider state it needs + // visible (by calling pollProviders). This is the contract the nicknames + // module's persistExternalEditAsBase relies on so it can strip live wraps + // (e.g. AFK) out of the residue at restart — otherwise a user whose + // nickname is "[AFK] Alice" at shutdown would have "[AFK] Alice" saved + // as the new base and the AFK provider would re-wrap it to + // "[AFK] [AFK] Alice" on the next render. + const client = makeClientStub(); + client.modules = {afk: {enabled: true}}; + const m = new NicknameManager(client); + m.install(); + + const seenContribCounts = []; + m.registerProvider('afk', 'afk', async () => ([{ + source: 'afk', + position: 'wrap', + value: (s) => '[AFK] ' + s, + priority: 500 + }])); + m.setBootstrapMemberHook(async (member) => { + await m.pollProviders(member); + seenContribCounts.push(m.getContributions(member.id).length); + }); + + const member = makeMember('1', 'Alice', '[AFK] Alice'); + client.guild.members.cache.set('1', member); + + client.emit('botReady'); + for (let i = 0; i < 5; i = i + 1) await tick(); + + expect(seenContribCounts).toEqual([1]); + }); + + test('handleConfigReload preserves memberRefs so subsequent requestUpdate still flushes', async () => { + // configReload wipes contributions but must NOT drop memberRefs — members + // didn't actually leave the guild. Modules with timed handlers (e.g. mute + // expiry) call requestUpdate after a reload and would silently no-op if + // the ref was dropped, because flushMember bails when memberRefs lookup + // returns undefined. + const client = makeClientStub(); + client.modules = {streak: {enabled: true}}; + const m = new NicknameManager(client); + m.install(); + + m.registerProvider('nicknames', 'streak', async () => ([ + {source: 'nicknames:base', position: 'base', value: 'Alice', priority: 100}, + {source: 'streak', position: 'suffix', value: ' 🔥1', priority: 1} + ])); + + const member = makeMember('1', 'Alice', null); + m.attachMember(member); + + client.emit('configReload'); + + m.requestUpdate('1'); + await tick(); + + expect(member.setNickname).toHaveBeenCalledWith('Alice 🔥1', expect.any(String)); + }); + + test('guildMemberUpdate triggers a flush on manual nick change when nicknames module is disabled', async () => { + // With the nicknames module off, no other listener will requestUpdate + // after a manual nickname edit. The manager must schedule the flush + // itself so decorating modules (here: streak) can re-apply their + // contributions on top of the new base. Without this, a user who + // manually removes their streak suffix would keep the bare nickname + // until some unrelated event eventually fires. + const client = makeClientStub(); + client.modules = {nicknames: {enabled: false}, streak: {enabled: true}}; + const m = new NicknameManager(client); + m.install(); + + m.registerProvider('streak', 'streak', async () => ({ + source: 'streak', + position: 'suffix', + value: ' 🔥3', + match: / 🔥\d+/, + priority: 1 + })); + + const oldM = makeMember('1', 'Alice', 'Alice 🔥3'); + const newM = makeMember('1', 'Alice', 'Bob'); + client.emit('guildMemberUpdate', oldM, newM); + for (let i = 0; i < 5; i = i + 1) await tick(); + + expect(newM.setNickname).toHaveBeenCalledWith('Bob 🔥3', expect.any(String)); + }); + + test('guildMemberUpdate does not race the owning module on a manual edit', async () => { + // Simulates the all-modules-enabled scenario: a user manually changes their + // nickname. The nicknames module's own guildMemberUpdate handler awaits a DB + // write (persistExternalEditAsBase) before requesting an update. If the + // manager re-rendered from its own synchronous handler, the flush would + // poll the provider with a stale base and clobber the manual edit. The + // manager must not schedule a flush on its own from this event. + const client = makeClientStub(); + client.modules = {nicknames: {enabled: true}}; + const m = new NicknameManager(client); + m.install(); + + // Provider returns a stale base — the value User.nickname held before the + // manual edit was persisted. If a flush runs now, it would set this stale + // value back, reverting the user's change. + m.registerProvider('nicknames', 'nicknames', async () => ([{ + source: 'nicknames:base', + position: 'base', + value: 'Albi', + priority: 100 + }])); + + const oldM = makeMember('1', 'Albi', 'Albi'); + const newM = makeMember('1', 'Albi', 'Dr. rer. nat. Albj'); + client.emit('guildMemberUpdate', oldM, newM); + for (let i = 0; i < 5; i = i + 1) await tick(); + + expect(newM.setNickname).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/nicknames/manager.providers.test.js b/tests/nicknames/manager.providers.test.js new file mode 100644 index 00000000..d2565b74 --- /dev/null +++ b/tests/nicknames/manager.providers.test.js @@ -0,0 +1,137 @@ +const NicknameManager = require('../../src/functions/nicknameManager'); + +/** + * Builds a NicknameManager bound to a client stub with the given module map. + * @param {Object} [modules] + * @returns {NicknameManager} + */ +function makeManager(modules = {}) { + return new NicknameManager({modules}); +} + +/** + * Builds a minimal GuildMember-shaped object. + * @param {string} id + * @returns {Object} + */ +function makeMember(id) { + return { + id, + nickname: null, + user: {displayName: 'X'} + }; +} + +describe('NicknameManager providers', () => { + test('registerProvider stores provider with moduleName', () => { + const m = makeManager(); + + /** + * Sample provider used to verify storage shape. + * @returns {Promise} + */ + async function fn() { + return null; + } + + m.registerProvider('src-a', 'mod-a', fn); + expect(m.providers.get('src-a')).toEqual({ + moduleName: 'mod-a', + fn + }); + }); + + test('unregisterProvider removes provider', () => { + const m = makeManager(); + m.registerProvider('src-a', 'mod-a', async () => null); + m.unregisterProvider('src-a'); + expect(m.providers.has('src-a')).toBe(false); + }); + + test('clearAllForSource removes contribution from all members', () => { + const m = makeManager(); + m.set('1', 'src-a', { + position: 'suffix', + value: ' A', + priority: 1 + }); + m.set('2', 'src-a', { + position: 'suffix', + value: ' A', + priority: 1 + }); + m.set('1', 'src-b', { + position: 'suffix', + value: ' B', + priority: 1 + }); + m.clearAllForSource('src-a'); + expect(m.members.get('1').contributions.has('src-a')).toBe(false); + expect(m.members.get('1').contributions.has('src-b')).toBe(true); + expect(m.members.get('2').contributions.has('src-a')).toBe(false); + }); + + test('pollProviders runs all providers and stores results', async () => { + const m = makeManager({'mod-a': {enabled: true}}); + m.registerProvider('src-a', 'mod-a', async () => ({ + position: 'suffix', + value: ' A', + priority: 1 + })); + const member = makeMember('1'); + await m.pollProviders(member); + expect(m.members.get('1').contributions.get('src-a').value).toBe(' A'); + }); + + test('pollProviders skips providers from disabled modules', async () => { + const m = makeManager({'mod-a': {enabled: false}}); + m.registerProvider('src-a', 'mod-a', async () => ({ + position: 'suffix', + value: ' A', + priority: 1 + })); + const member = makeMember('1'); + await m.pollProviders(member); + expect(m.members.get('1')?.contributions?.has('src-a')).toBeFalsy(); + }); + + test('pollProviders supports providers returning arrays', async () => { + const m = makeManager({'mod-a': {enabled: true}}); + m.registerProvider('src-a', 'mod-a', async () => [ + { + source: 'src-a:1', + position: 'prefix', + value: 'P', + priority: 10 + }, + { + source: 'src-a:2', + position: 'suffix', + value: 'S', + priority: 1 + } + ]); + const member = makeMember('1'); + await m.pollProviders(member); + const c = m.members.get('1').contributions; + expect(c.get('src-a:1').value).toBe('P'); + expect(c.get('src-a:2').value).toBe('S'); + }); + + test('pollProviders clears prior contribution if provider returns null', async () => { + const m = makeManager({'mod-a': {enabled: true}}); + let returnValue = { + position: 'suffix', + value: ' A', + priority: 1 + }; + m.registerProvider('src-a', 'mod-a', async () => returnValue); + const member = makeMember('1'); + await m.pollProviders(member); + expect(m.members.get('1').contributions.has('src-a')).toBe(true); + + returnValue = null; + await m.pollProviders(member); + expect(m.members.get('1').contributions.has('src-a')).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/nicknames/manager.render.test.js b/tests/nicknames/manager.render.test.js new file mode 100644 index 00000000..9f399e36 --- /dev/null +++ b/tests/nicknames/manager.render.test.js @@ -0,0 +1,226 @@ +const NicknameManager = require('../../src/functions/nicknameManager'); + +/** + * Builds a fresh NicknameManager bound to a minimal client stub. + * @returns {NicknameManager} + */ +function makeManager() { + const client = {user: {displayName: 'fallback'}}; + return new NicknameManager(client); +} + +/** + * Builds a minimal GuildMember-shaped object for render tests. + * @param {string} id + * @param {string} displayName + * @param {string|null} [nickname] + * @returns {Object} + */ +function makeMember(id, displayName, nickname) { + return { + id, + nickname: nickname ?? null, + user: {displayName} + }; +} + +describe('NicknameManager.render', () => { + test('returns displayName when no contributions', () => { + const m = makeManager(); + expect(m.render(makeMember('1', 'Alice'))).toBe('Alice'); + }); + + test('uses highest-priority base contribution', () => { + const m = makeManager(); + m.set('1', 'src-a', { + position: 'base', + value: 'A', + priority: 10 + }); + m.set('1', 'src-b', { + position: 'base', + value: 'B', + priority: 100 + }); + expect(m.render(makeMember('1', 'X'))).toBe('B'); + }); + + test('appends suffix to base', () => { + const m = makeManager(); + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥3', + priority: 1 + }); + expect(m.render(makeMember('1', 'Alice'))).toBe('Alice 🔥3'); + }); + + test('prepends prefix to base', () => { + const m = makeManager(); + m.set('1', 'role', { + position: 'prefix', + value: '[VIP] ', + priority: 10 + }); + expect(m.render(makeMember('1', 'Alice'))).toBe('[VIP] Alice'); + }); + + test('combines prefix + base + suffix', () => { + const m = makeManager(); + m.set('1', 'role-pre', { + position: 'prefix', + value: '[VIP] ', + priority: 10 + }); + m.set('1', 'role-suf', { + position: 'suffix', + value: ' ❤', + priority: 10 + }); + m.set('1', 'streak', { + position: 'suffix', + value: ' 🔥3', + priority: 1 + }); + expect(m.render(makeMember('1', 'Alice'))).toBe('[VIP] Alice ❤ 🔥3'); + }); + + test('multiple prefixes order by priority desc (highest closest to base)', () => { + const m = makeManager(); + m.set('1', 'outer', { + position: 'prefix', + value: '<<', + priority: 1 + }); + m.set('1', 'inner', { + position: 'prefix', + value: '>>', + priority: 10 + }); + expect(m.render(makeMember('1', 'X'))).toBe('<<>>X'); + }); + + test('baseTransform mutates base before prefix/suffix', () => { + const m = makeManager(); + m.set('1', 'sanitize', { + position: 'baseTransform', + value: (b) => b.toUpperCase(), + priority: 50 + }); + m.set('1', 'role', { + position: 'prefix', + value: '[VIP] ', + priority: 10 + }); + expect(m.render(makeMember('1', 'alice'))).toBe('[VIP] ALICE'); + }); + + test('wrap runs after assembly', () => { + const m = makeManager(); + m.set('1', 'role', { + position: 'prefix', + value: '[VIP] ', + priority: 10 + }); + m.set('1', 'mute', { + position: 'wrap', + value: (s) => '[Muted] ' + s, + priority: 1000 + }); + expect(m.render(makeMember('1', 'Alice'))).toBe('[Muted] [VIP] Alice'); + }); + + test('two wraps stack innermost-first by priority desc', () => { + const m = makeManager(); + m.set('1', 'inner', { + position: 'wrap', + value: (s) => '<' + s + '>', + priority: 100 + }); + m.set('1', 'outer', { + position: 'wrap', + value: (s) => '[' + s + ']', + priority: 10 + }); + expect(m.render(makeMember('1', 'X'))).toBe('[]'); + }); + + test('exclusive prefix: highest-priority exclusive wins, non-exclusive still renders', () => { + const m = makeManager(); + m.set('1', 'ex-low', { + position: 'prefix', + value: 'L', + priority: 1, + exclusive: true + }); + m.set('1', 'ex-high', { + position: 'prefix', + value: 'H', + priority: 100, + exclusive: true + }); + m.set('1', 'free', { + position: 'prefix', + value: 'F', + priority: 50 + }); + + /* + * Exclusive group: H wins over L. Non-exclusive F always renders. + * Ordering of all rendered prefixes: exclusive winner H first, then F. + */ + expect(m.render(makeMember('1', 'X'))).toBe('HFX'); + }); + + test('truncates to 32 chars', () => { + const m = makeManager(); + m.set('1', 'pre', { + position: 'prefix', + value: 'PREFIX-LONG-', + priority: 10 + }); + expect(m.render(makeMember('1', 'BaseNameThatIsAlsoQuiteLong'))).toHaveLength(32); + }); + + test('global baseTransform applies to all members', () => { + const m = makeManager(); + m.registerGlobalTransform('cleaner', 'name-list-cleaner', { + position: 'baseTransform', + value: (b) => b.replace(/^[^A-Z]+/, ''), + priority: 50 + }); + // No per-member contribution; uses displayName as base. + expect(m.render(makeMember('1', '!!!Alice'))).toBe('Alice'); + }); + + test('global wrap applies to all members', () => { + const m = makeManager(); + m.registerGlobalTransform('decorator', 'some-module', { + position: 'wrap', + value: (s) => '*' + s + '*', + priority: 1 + }); + expect(m.render(makeMember('1', 'X'))).toBe('*X*'); + }); + + test('baseTransform value receives member as second argument', () => { + const m = makeManager(); + const seen = []; + m.registerGlobalTransform('inspector', 'some-module', { + position: 'baseTransform', + value: (base, member) => { + seen.push({ + base, + memberId: member?.id + }); + return base; + }, + priority: 10 + }); + m.render(makeMember('42', 'Alice')); + expect(seen).toEqual([{ + base: 'Alice', + memberId: '42' + }]); + }); +}); \ No newline at end of file diff --git a/tests/nicknames/onLoad.test.js b/tests/nicknames/onLoad.test.js new file mode 100644 index 00000000..2e395461 --- /dev/null +++ b/tests/nicknames/onLoad.test.js @@ -0,0 +1,200 @@ +/* + * Tests for nicknames onLoad: registers a single provider + a bootstrap hook, + * guards against double registration, and exercises the provider's output: + * - returns null when config/strings are missing + * - base name from stored nickname vs forceDisplayname + * - highest-position matching role contributes prefix/suffix + * Also checks the bootstrap hook skips a disabled module. + */ +const mockPersist = jest.fn().mockResolvedValue(); +jest.mock('../../modules/nicknames/persistExternalEditAsBase', () => ({ + persistExternalEditAsBase: (...a) => mockPersist(...a) +})); + +const {onLoad} = require('../../modules/nicknames/onLoad'); + +function makeClient({ + config, + strings, + stored = null, + modules = {} + } = {}) { + let provider = null; + let bootstrapHook = null; + const client = { + modules, + configurations: { + nicknames: { + config, + strings + } + }, + models: {nicknames: {User: {findOne: jest.fn().mockResolvedValue(stored)}}}, + nicknameManager: { + registerProvider: jest.fn((source, name, fn) => { + provider = fn; + }), + setBootstrapMemberHook: jest.fn((fn) => { + bootstrapHook = fn; + }) + } + }; + onLoad(client); + return { + client, + getProvider: () => provider, + getHook: () => bootstrapHook + }; +} + +function makeMember({ + id = 'm1', + displayName = 'Display', + roles = [] + } = {}) { + return { + id, + user: {displayName}, + roles: {cache: {values: () => roles[Symbol.iterator]()}} + }; +} + +beforeEach(() => mockPersist.mockClear()); + +test('registers a provider and a bootstrap hook once', () => { + const {client} = makeClient({ + config: {}, + strings: [] + }); + expect(client.nicknameManager.registerProvider).toHaveBeenCalledTimes(1); + expect(client.nicknameManager.setBootstrapMemberHook).toHaveBeenCalledTimes(1); + expect(client.nicknamesProviderRegistered).toBe(true); +}); + +test('does not register twice', () => { + const {client} = makeClient({ + config: {}, + strings: [] + }); + onLoad(client); + expect(client.nicknameManager.registerProvider).toHaveBeenCalledTimes(1); +}); + +describe('provider output', () => { + test('returns null when config or strings are missing', async () => { + const {getProvider} = makeClient({ + config: undefined, + strings: undefined + }); + expect(await getProvider()(makeMember())).toBeNull(); + }); + + test('uses the stored nickname as the base name', async () => { + const {getProvider} = makeClient({ + config: {forceDisplayname: false}, + strings: [], + stored: {nickname: 'StoredName'} + }); + const out = await getProvider()(makeMember({displayName: 'Display'})); + const base = out.find(c => c.position === 'base'); + expect(base.value).toBe('StoredName'); + }); + + test('forceDisplayname overrides the stored nickname', async () => { + const {getProvider} = makeClient({ + config: {forceDisplayname: true}, + strings: [], + stored: {nickname: 'StoredName'} + }); + const out = await getProvider()(makeMember({displayName: 'Display'})); + expect(out.find(c => c.position === 'base').value).toBe('Display'); + }); + + test('falls back to displayName when there is no stored row', async () => { + const {getProvider} = makeClient({ + config: {}, + strings: [], + stored: null + }); + const out = await getProvider()(makeMember({displayName: 'Display'})); + expect(out.find(c => c.position === 'base').value).toBe('Display'); + }); + + test('contributes prefix/suffix from the highest-position matching role', async () => { + const strings = [ + { + roleID: 'low', + prefix: '[L] ' + }, + { + roleID: 'high', + prefix: '[H] ', + suffix: ' !' + } + ]; + const {getProvider} = makeClient({ + config: {}, + strings, + stored: {nickname: 'N'} + }); + const roles = [ + { + id: 'low', + position: 1 + }, + { + id: 'high', + position: 9 + } + ]; + const out = await getProvider()(makeMember({roles})); + const prefix = out.find(c => c.position === 'prefix'); + const suffix = out.find(c => c.position === 'suffix'); + expect(prefix.value).toBe('[H] '); + expect(suffix.value).toBe(' !'); + }); + + test('omits prefix/suffix when no role matches', async () => { + const {getProvider} = makeClient({ + config: {}, + strings: [{ + roleID: 'x', + prefix: '[X] ' + }], + stored: {nickname: 'N'} + }); + const out = await getProvider()(makeMember({ + roles: [{ + id: 'y', + position: 1 + }] + })); + expect(out.some(c => c.position === 'prefix')).toBe(false); + }); +}); + +describe('bootstrap hook', () => { + test('persists the base for an enabled module', async () => { + const { + getHook, + client + } = makeClient({ + config: {}, + strings: [], + modules: {nicknames: {enabled: true}} + }); + const member = makeMember(); + await getHook()(member); + expect(mockPersist).toHaveBeenCalledWith(client, member); + }); + + test('skips persistence when the module is disabled', async () => { + const {getHook} = makeClient({ + config: {}, + strings: [], + modules: {nicknames: {enabled: false}} + }); + await getHook()(makeMember()); + expect(mockPersist).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/nicknames/persistExternalEditAsBase.test.js b/tests/nicknames/persistExternalEditAsBase.test.js new file mode 100644 index 00000000..215a36ab --- /dev/null +++ b/tests/nicknames/persistExternalEditAsBase.test.js @@ -0,0 +1,160 @@ +const {persistExternalEditAsBase} = require('../../modules/nicknames/persistExternalEditAsBase'); + +/** + * Builds a fake client with in-memory User store and a configurable role list. + * @param {Array} roles configured roles (each with prefix/suffix) + * @param {Object} [config] nicknames config block + * @returns {Object} + */ +function makeClient(roles, config = {forceDisplayname: false}) { + const store = new Map(); + return { + models: { + nicknames: { + User: { + findOne: async ({where}) => store.get(where.userID) ?? null, + create: async (data) => { + store.set(data.userID, { + ...data, + save: async () => { + } + }); + return store.get(data.userID); + } + } + } + }, + configurations: { + nicknames: { + strings: roles, + config + } + }, + logger: { + warn() { + } + }, + nicknameManager: null, + store + }; +} + +/** + * Builds a minimal GuildMember-shaped object. + * @param {string} id + * @param {string} displayName + * @param {string|null} [nickname] + * @returns {Object} + */ +function makeMember(id, displayName, nickname) { + return { + id, + nickname: nickname ?? null, + user: {displayName} + }; +} + +describe('persistExternalEditAsBase', () => { + test('strips a single role suffix once', async () => { + const client = makeClient([{ + roleID: 'r', + prefix: '', + suffix: ' t' + }]); + await persistExternalEditAsBase(client, makeMember('1', 'Alice', 'Alice t')); + expect(client.store.get('1').nickname).toBe('Alice'); + }); + + test('strips repeated role suffix down to clean base (regression: cmoplc + role suffix)', async () => { + const client = makeClient([{ + roleID: 'r', + prefix: '', + suffix: ' t' + }]); + await persistExternalEditAsBase(client, makeMember('1', 'Alice', 'Alice t t t t t t t t')); + expect(client.store.get('1').nickname).toBe('Alice'); + }); + + test('strips repeated role prefix down to clean base', async () => { + const client = makeClient([{ + roleID: 'r', + prefix: '[VIP] ', + suffix: '' + }]); + await persistExternalEditAsBase(client, makeMember('1', 'Alice', '[VIP] [VIP] [VIP] Alice')); + expect(client.store.get('1').nickname).toBe('Alice'); + }); + + // TODO(nicknames-bootstrap-streak-bug): persistExternalEditAsBase only strips + // streak suffixes via live nicknameManager contributions. With no manager + // populated (bootstrap / right after restart), historical "fire-digit" residue + // from past activity-streak runs is never removed. Fix requires either a + // hardcoded fallback regex or guaranteeing the manager is hydrated before + // this runs. Out of scope for the dep-cleanup pass. + test.skip('strips repeated streak suffixes', async () => { + const client = makeClient([]); + await persistExternalEditAsBase(client, makeMember('1', 'Alice', 'Alice 🔥3 🔥5 🔥7')); + expect(client.store.get('1').nickname).toBe('Alice'); + }); + + test('idempotent on already-clean base', async () => { + const client = makeClient([{ + roleID: 'r', + prefix: '', + suffix: ' t' + }]); + await persistExternalEditAsBase(client, makeMember('1', 'Alice', 'Alice')); + expect(client.store.get('1').nickname).toBe('Alice'); + }); + + // TODO(nicknames-bootstrap-streak-bug): same root cause as the skipped test + // above - trailing streak residue blocks role-suffix stripping because the + // residue does not endsWith(' t'). Fix tracked separately. + test.skip('handles combination of role suffix and trailing streak', async () => { + const client = makeClient([{ + roleID: 'r', + prefix: '', + suffix: ' t' + }]); + await persistExternalEditAsBase(client, makeMember('1', 'Alice', 'Alice t t t 🔥5')); + expect(client.store.get('1').nickname).toBe('Alice'); + }); + + test('falls back to displayName when residue empties out', async () => { + const client = makeClient([{ + roleID: 'r', + prefix: '', + suffix: 'Alice' + }]); + await persistExternalEditAsBase(client, makeMember('1', 'Bob', 'Alice')); + expect(client.store.get('1').nickname).toBe('Bob'); + }); + + test('forceDisplayname overrides residue', async () => { + const client = makeClient([{ + roleID: 'r', + prefix: '', + suffix: ' t' + }], {forceDisplayname: true}); + await persistExternalEditAsBase(client, makeMember('1', 'Bob', 'CustomName t t')); + expect(client.store.get('1').nickname).toBe('Bob'); + }); + + test('updates an existing User row when residue differs', async () => { + const client = makeClient([{ + roleID: 'r', + prefix: '', + suffix: ' t' + }]); + // Pre-populate a stale row. + let saved = null; + client.models.nicknames.User.findOne = async () => ({ + nickname: 'Alice t t t t', + save: async function () { + saved = this.nickname; + } + }); + await persistExternalEditAsBase(client, makeMember('1', 'Alice', 'Alice t t t t t t t t')); + expect(saved).toBe('Alice'); + }); +}); \ No newline at end of file diff --git a/tests/ping-on-vc-join/notifyPipeline.test.js b/tests/ping-on-vc-join/notifyPipeline.test.js new file mode 100644 index 00000000..81a13fd6 --- /dev/null +++ b/tests/ping-on-vc-join/notifyPipeline.test.js @@ -0,0 +1,312 @@ +/* + * Tests for the async notify pipeline of ping-on-vc-join's voiceStateUpdate + * handler: the part that runs after the synchronous voice-role assignment. + * + * Covers: + * - cross-guild guard (channel belongs to another guild) + * - unconfigured channel -> no notify + * - bot members ignored + * - missing notify channel -> disableModule called + * - 3s delayed ping: send happens, with placeholders substituted + * - ping skipped if the member left the channel during the delay + * - legacy per-user cooldown: second join within window is suppressed + * - per-channel cooldown when cooldownEnabled + * - optional DM (send_pn_to_member) + * + * helpers are mocked so embedType/disableModule/formatDiscordUserName are + * deterministic and disableModule does not touch the real main client. + */ +jest.useFakeTimers(); + +const mockDisableModule = jest.fn(); +jest.mock('../../src/functions/helpers', () => ({ + embedType: (msg, args) => ({ + message: msg, + args + }), + disableModule: (...a) => mockDisableModule(...a), + formatDiscordUserName: (user) => `tag:${user.id}` +})); + +const handler = require('../../modules/ping-on-vc-join/events/voiceStateUpdate'); + +function makeNotifyChannel() { + return {send: jest.fn().mockResolvedValue({id: 'sent'})}; +} + +function makeMember({ + bot = false, + id = 'u1', + channelId = 'vc1' + } = {}) { + return { + user: { + bot, + id, + send: jest.fn().mockResolvedValue() + }, + send: jest.fn().mockResolvedValue(), + voice: {channelId}, + roles: { + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + } + }; +} + +function makeClient({ + moduleConfig, + notifyChannel, + member, + channelGuildID = 'g1' + } = {}) { + const channel = { + id: 'vc1', + name: 'General', + guild: {id: channelGuildID} + }; + return { + botReadyAt: Date.now(), + guild: { + id: 'g1', + channels: {cache: {get: jest.fn((id) => (notifyChannel && id === 'notify1' ? notifyChannel : undefined))}}, + members: {fetch: jest.fn().mockResolvedValue(member)} + }, + channels: {fetch: jest.fn().mockResolvedValue(channel)}, + logger: {info: jest.fn()}, + configurations: { + 'ping-on-vc-join': { + 'actual-config': { + assignRoleToUsersInVoiceChannels: false, + voiceRoles: [] + }, + config: moduleConfig + } + }, + _channel: channel + }; +} + +function newStateFor(member, guild) { + return { + member, + channel: {id: 'vc1'}, + channelId: 'vc1', + id: member.user.id, + guild + }; +} + +const baseElement = { + channels: ['vc1'], + notify_channel_id: 'notify1', + message: 'msg', + pn_message: 'pn' +}; + +beforeEach(() => { + mockDisableModule.mockClear(); +}); + +test('ignores a channel that belongs to another guild', async () => { + const member = makeMember(); + const notifyChannel = makeNotifyChannel(); + const client = makeClient({ + moduleConfig: [baseElement], + notifyChannel, + member, + channelGuildID: 'other' + }); + await handler.run(client, {channel: null}, newStateFor(member, client.guild)); + jest.runOnlyPendingTimers(); + expect(notifyChannel.send).not.toHaveBeenCalled(); +}); + +test('does nothing when the channel is not configured', async () => { + const member = makeMember(); + const notifyChannel = makeNotifyChannel(); + const client = makeClient({ + moduleConfig: [{ + ...baseElement, + channels: ['other-vc'] + }], + notifyChannel, + member + }); + await handler.run(client, {channel: null}, newStateFor(member, client.guild)); + jest.runOnlyPendingTimers(); + expect(notifyChannel.send).not.toHaveBeenCalled(); +}); + +test('ignores bot members joining a configured channel', async () => { + const member = makeMember({bot: true}); + const notifyChannel = makeNotifyChannel(); + const client = makeClient({ + moduleConfig: [baseElement], + notifyChannel, + member + }); + await handler.run(client, {channel: null}, newStateFor(member, client.guild)); + jest.runOnlyPendingTimers(); + expect(notifyChannel.send).not.toHaveBeenCalled(); +}); + +test('disables the module when the notify channel is missing', async () => { + const member = makeMember(); + const client = makeClient({ + moduleConfig: [baseElement], + notifyChannel: null, + member + }); + await handler.run(client, {channel: null}, newStateFor(member, client.guild)); + expect(mockDisableModule).toHaveBeenCalledWith('ping-on-vc-join', expect.any(String)); +}); + +test('sends the ping after the 3s delay with placeholders substituted', async () => { + const member = makeMember(); + const notifyChannel = makeNotifyChannel(); + const client = makeClient({ + moduleConfig: [baseElement], + notifyChannel, + member + }); + await handler.run(client, {channel: null}, newStateFor(member, client.guild)); + expect(notifyChannel.send).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(3000); + expect(notifyChannel.send).toHaveBeenCalledTimes(1); + const payload = notifyChannel.send.mock.calls[0][0]; + expect(payload.args['%vc%']).toBe('General'); + expect(payload.args['%tag%']).toBe('tag:u1'); + expect(payload.args['%mention%']).toBe('<@u1>'); +}); + +test('does not ping if the member left the channel during the delay', async () => { + const member = makeMember({id: 'left-user'}); + const notifyChannel = makeNotifyChannel(); + const client = makeClient({ + moduleConfig: [baseElement], + notifyChannel, + member + }); + await handler.run(client, {channel: null}, newStateFor(member, client.guild)); + member.voice.channelId = 'somewhere-else'; + await jest.advanceTimersByTimeAsync(3000); + expect(notifyChannel.send).not.toHaveBeenCalled(); +}); + +test('does not ping if the member fully disconnected during the delay', async () => { + const member = makeMember({id: 'disc-user'}); + const notifyChannel = makeNotifyChannel(); + const client = makeClient({ + moduleConfig: [baseElement], + notifyChannel, + member + }); + await handler.run(client, {channel: null}, newStateFor(member, client.guild)); + member.voice = null; + await jest.advanceTimersByTimeAsync(3000); + expect(notifyChannel.send).not.toHaveBeenCalled(); +}); + +test('sends an optional DM when send_pn_to_member is set', async () => { + // unique id: the legacy per-user cooldown is module-level state shared across tests + const member = makeMember({id: 'dm-user'}); + const notifyChannel = makeNotifyChannel(); + const client = makeClient({ + moduleConfig: [{ + ...baseElement, + send_pn_to_member: true + }], + notifyChannel, + member + }); + await handler.run(client, {channel: null}, newStateFor(member, client.guild)); + await jest.advanceTimersByTimeAsync(3000); + expect(member.send).toHaveBeenCalledTimes(1); +}); + +test('legacy per-user cooldown suppresses a second ping within the window', async () => { + const notifyChannel = makeNotifyChannel(); + const moduleConfig = [baseElement]; + + const member1 = makeMember({id: 'cool-u'}); + const client1 = makeClient({ + moduleConfig, + notifyChannel, + member: member1 + }); + await handler.run(client1, {channel: null}, newStateFor(member1, client1.guild)); + await jest.advanceTimersByTimeAsync(3000); + expect(notifyChannel.send).toHaveBeenCalledTimes(1); + + // second join for the same user, still within the 5-minute cooldown + const member2 = makeMember({id: 'cool-u'}); + const client2 = makeClient({ + moduleConfig, + notifyChannel, + member: member2 + }); + await handler.run(client2, {channel: null}, newStateFor(member2, client2.guild)); + await jest.advanceTimersByTimeAsync(3000); + expect(notifyChannel.send).toHaveBeenCalledTimes(1); // unchanged +}); + +test('per-channel cooldown suppresses a repeat ping in the same channel', async () => { + const notifyChannel = makeNotifyChannel(); + const element = { + ...baseElement, + channels: ['vc-cd'], + cooldownEnabled: true, + cooldownMinutes: 5 + }; + + const memberA = makeMember({id: 'a'}); + const clientA = makeClient({ + moduleConfig: [element], + notifyChannel, + member: memberA + }); + clientA._channel.id = 'vc-cd'; + clientA.channels.fetch.mockResolvedValue({ + id: 'vc-cd', + name: 'CD', + guild: {id: 'g1'} + }); + clientA.guild.members.fetch.mockResolvedValue(memberA); + memberA.voice.channelId = 'vc-cd'; + const nsA = { + member: memberA, + channel: {id: 'vc-cd'}, + channelId: 'vc-cd', + id: 'a', + guild: clientA.guild + }; + await handler.run(clientA, {channel: null}, nsA); + await jest.advanceTimersByTimeAsync(3000); + expect(notifyChannel.send).toHaveBeenCalledTimes(1); + + const memberB = makeMember({id: 'b'}); + const clientB = makeClient({ + moduleConfig: [element], + notifyChannel, + member: memberB + }); + clientB.channels.fetch.mockResolvedValue({ + id: 'vc-cd', + name: 'CD', + guild: {id: 'g1'} + }); + clientB.guild.members.fetch.mockResolvedValue(memberB); + memberB.voice.channelId = 'vc-cd'; + const nsB = { + member: memberB, + channel: {id: 'vc-cd'}, + channelId: 'vc-cd', + id: 'b', + guild: clientB.guild + }; + await handler.run(clientB, {channel: null}, nsB); + await jest.advanceTimersByTimeAsync(3000); + // channel still on cooldown -> no second send + expect(notifyChannel.send).toHaveBeenCalledTimes(1); +}); \ No newline at end of file diff --git a/tests/ping-on-vc-join/voiceStateUpdate.test.js b/tests/ping-on-vc-join/voiceStateUpdate.test.js new file mode 100644 index 00000000..b3617606 --- /dev/null +++ b/tests/ping-on-vc-join/voiceStateUpdate.test.js @@ -0,0 +1,166 @@ +/* + * Behavioural tests for ping-on-vc-join's voiceStateUpdate handler. + * + * Focus on the synchronous, branch-heavy part of run(): the optional + * "assign a voice role while in any VC" feature. This runs before the async + * notify pipeline, so we can assert role add/remove without driving the + * 3-second ping timeout. Also covers the early guards (bot not ready, + * feature disabled, bot members ignored). + */ +const handler = require('../../modules/ping-on-vc-join/events/voiceStateUpdate'); + +function makeClient(roleConfig, {moduleConfig = []} = {}) { + return { + botReadyAt: Date.now(), + guild: { + id: 'g1', + channels: {cache: {get: () => undefined}}, + members: {fetch: jest.fn()} + }, + channels: { + fetch: jest.fn().mockResolvedValue({ + id: 'other', + guild: {id: 'g1'} + }) + }, + configurations: { + 'ping-on-vc-join': { + 'actual-config': roleConfig, + config: moduleConfig + } + } + }; +} + +function makeMember({bot = false} = {}) { + return { + user: { + bot, + id: 'u1' + }, + roles: { + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + } + }; +} + +const enabledRoleCfg = { + assignRoleToUsersInVoiceChannels: true, + voiceRoles: ['role-vc'] +}; + +describe('voice role assignment', () => { + test('adds the voice role when a user joins from no channel', async () => { + const member = makeMember(); + const client = makeClient(enabledRoleCfg); + const oldState = {channel: null}; + const newState = { + member, + channel: {id: 'vc1'}, + channelId: 'vc1', + id: 'u1', + guild: client.guild + }; + await handler.run(client, oldState, newState); + expect(member.roles.add).toHaveBeenCalledWith(['role-vc']); + expect(member.roles.remove).not.toHaveBeenCalled(); + }); + + test('removes the voice role when a user leaves to no channel', async () => { + const member = makeMember(); + const client = makeClient(enabledRoleCfg); + const oldState = {channel: {id: 'vc1'}}; + const newState = { + member, + channel: null, + channelId: null, + id: 'u1', + guild: client.guild + }; + await handler.run(client, oldState, newState); + expect(member.roles.remove).toHaveBeenCalledWith(['role-vc']); + expect(member.roles.add).not.toHaveBeenCalled(); + }); + + test('does nothing for role assignment when the feature is disabled', async () => { + const member = makeMember(); + const client = makeClient({ + assignRoleToUsersInVoiceChannels: false, + voiceRoles: ['role-vc'] + }); + const newState = { + member, + channel: {id: 'vc1'}, + channelId: 'vc1', + id: 'u1', + guild: client.guild + }; + await handler.run(client, {channel: null}, newState); + expect(member.roles.add).not.toHaveBeenCalled(); + }); + + test('skips bots for role assignment', async () => { + const member = makeMember({bot: true}); + const client = makeClient(enabledRoleCfg); + const newState = { + member, + channel: {id: 'vc1'}, + channelId: 'vc1', + id: 'u1', + guild: client.guild + }; + await handler.run(client, {channel: null}, newState); + expect(member.roles.add).not.toHaveBeenCalled(); + }); + + test('does not touch roles when voiceRoles list is empty', async () => { + const member = makeMember(); + const client = makeClient({ + assignRoleToUsersInVoiceChannels: true, + voiceRoles: [] + }); + const newState = { + member, + channel: {id: 'vc1'}, + channelId: 'vc1', + id: 'u1', + guild: client.guild + }; + await handler.run(client, {channel: null}, newState); + expect(member.roles.add).not.toHaveBeenCalled(); + }); +}); + +describe('early guards', () => { + test('returns immediately when the bot is not ready', async () => { + const member = makeMember(); + const client = makeClient(enabledRoleCfg); + client.botReadyAt = null; + const newState = { + member, + channel: {id: 'vc1'}, + channelId: 'vc1', + id: 'u1', + guild: client.guild + }; + await handler.run(client, {channel: null}, newState); + expect(member.roles.add).not.toHaveBeenCalled(); + }); + + test('does not re-fetch the channel when the user stayed in the same channel', async () => { + const member = makeMember(); + const client = makeClient(enabledRoleCfg); + const sameChannel = {id: 'vc1'}; + const newState = { + member, + channel: sameChannel, + channelId: 'vc1', + id: 'u1', + guild: client.guild + }; + await handler.run(client, {channel: sameChannel}, newState); + // same channel id -> the notify path returns before fetching + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/ping-protection/autoModEvent.test.js b/tests/ping-protection/autoModEvent.test.js new file mode 100644 index 00000000..fa1607e5 --- /dev/null +++ b/tests/ping-protection/autoModEvent.test.js @@ -0,0 +1,157 @@ +/* + * Tests for ping-protection's autoModerationActionExecution handler. It maps a + * blocked AutoMod keyword back to a protected role/user, resolves the origin + * channel, applies whitelist + ignored-user guards, and dispatches processPing + * for protected targets only. + */ +const mockProcessPing = jest.fn().mockResolvedValue(); +const mockIsWhitelisted = jest.fn(() => false); +jest.mock('../../modules/ping-protection/ping-protection', () => ({ + processPing: (...a) => mockProcessPing(...a), + isWhitelistedChannel: (...a) => mockIsWhitelisted(...a) +})); + +const handler = require('../../modules/ping-protection/events/autoModerationActionExecution'); + +function makeClient(configOverrides = {}) { + return { + configurations: { + 'ping-protection': { + configuration: { + ignoredUsers: [], + protectedRoles: [], + protectedUsers: [], + protectAllUsersWithProtectedRole: false, + ...configOverrides + } + } + } + }; +} + +function makeExecution({ + userId = 'pinger', + matchedKeyword = '<@victim>', + channel = {id: 'c1'}, + members = {} + } = {}) { + return { + ruleTriggerType: 1, + userId, + matchedKeyword, + channel, + guild: { + channels: {fetch: jest.fn().mockResolvedValue({id: 'fetched'})}, + members: { + fetch: jest.fn((id) => Promise.resolve(members[id] || { + id, + roles: {cache: {some: () => false}} + })) + } + } + }; +} + +beforeEach(() => { + mockProcessPing.mockClear(); + mockIsWhitelisted.mockClear(); + mockIsWhitelisted.mockReturnValue(false); +}); + +test('ignores non-keyword automod triggers', async () => { + const exec = makeExecution(); + exec.ruleTriggerType = 2; + await handler.run(makeClient(), exec); + expect(mockProcessPing).not.toHaveBeenCalled(); +}); + +test('ignores users on the ignore list', async () => { + const client = makeClient({ignoredUsers: ['pinger']}); + await handler.run(client, makeExecution()); + expect(mockProcessPing).not.toHaveBeenCalled(); +}); + +test('dispatches processPing when a protected user was pinged', async () => { + const client = makeClient({protectedUsers: ['111222']}); + const member = { + id: 'pinger', + roles: {cache: {some: () => false}} + }; + const exec = makeExecution({ + matchedKeyword: '<@111222>', + members: { + pinger: member, + '111222': {} + } + }); + await handler.run(client, exec); + expect(mockProcessPing).toHaveBeenCalledWith( + client, 'pinger', '111222', false, 'Blocked by AutoMod', exec.channel, member + ); +}); + +test('flags isRole true when a protected role keyword matched', async () => { + const client = makeClient({protectedRoles: ['999888']}); + const member = { + id: 'pinger', + roles: {cache: {some: () => false}} + }; + const exec = makeExecution({ + matchedKeyword: '<@&999888>', + members: {pinger: member} + }); + await handler.run(client, exec); + expect(mockProcessPing).toHaveBeenCalledWith( + client, 'pinger', '999888', true, 'Blocked by AutoMod', exec.channel, member + ); +}); + +test('does nothing when the target is not protected', async () => { + const client = makeClient({protectedUsers: ['someone-else']}); + await handler.run(client, makeExecution({matchedKeyword: '<@111222>'})); + expect(mockProcessPing).not.toHaveBeenCalled(); +}); + +test('skips when the origin channel is whitelisted', async () => { + mockIsWhitelisted.mockReturnValue(true); + const client = makeClient({protectedUsers: ['victim']}); + await handler.run(client, makeExecution()); + expect(mockProcessPing).not.toHaveBeenCalled(); +}); + +test('resolves protectAllUsersWithProtectedRole by inspecting the target member', async () => { + const client = makeClient({ + protectAllUsersWithProtectedRole: true, + protectedRoles: ['roleX'] + }); + const pinger = { + id: 'pinger', + roles: {cache: {some: () => false}} + }; + const protectedTarget = {roles: {cache: {some: (fn) => fn({id: 'roleX'})}}}; + const exec = makeExecution({ + matchedKeyword: '<@333444>', + members: { + pinger, + '333444': protectedTarget + } + }); + await handler.run(client, exec); + expect(mockProcessPing).toHaveBeenCalled(); +}); + +test('fetches the origin channel by id when channel is absent', async () => { + const client = makeClient({protectedUsers: ['111222']}); + const pinger = { + id: 'pinger', + roles: {cache: {some: () => false}} + }; + const exec = makeExecution({ + channel: null, + matchedKeyword: '<@111222>', + members: {pinger} + }); + exec.channelId = 'c-by-id'; + await handler.run(client, exec); + expect(exec.guild.channels.fetch).toHaveBeenCalledWith('c-by-id'); +}); \ No newline at end of file diff --git a/tests/ping-protection/botReady.test.js b/tests/ping-protection/botReady.test.js new file mode 100644 index 00000000..0802d7a8 --- /dev/null +++ b/tests/ping-protection/botReady.test.js @@ -0,0 +1,45 @@ +/* + * Tests for ping-protection/botReady: it runs retention enforcement and AutoMod + * sync immediately, then schedules a daily 03:00 job that repeats both, pushing + * the job onto client.jobs. + */ +const mockEnforce = jest.fn().mockResolvedValue(); +const mockSync = jest.fn().mockResolvedValue(); +const mockScheduleJob = jest.fn(() => 'job'); +jest.mock('../../modules/ping-protection/ping-protection', () => ({ + enforceRetention: (...a) => mockEnforce(...a), + syncNativeAutoMod: (...a) => mockSync(...a) +})); +jest.mock('node-schedule', () => ({scheduleJob: (...a) => mockScheduleJob(...a)})); + +const handler = require('../../modules/ping-protection/events/botReady'); + +beforeEach(() => { + mockEnforce.mockClear(); + mockSync.mockClear(); + mockScheduleJob.mockClear(); +}); + +test('runs retention + automod sync on startup and registers the daily job', async () => { + const client = {jobs: []}; + await handler.run(client); + expect(mockEnforce).toHaveBeenCalledTimes(1); + expect(mockSync).toHaveBeenCalledTimes(1); + expect(mockScheduleJob).toHaveBeenCalledWith('0 3 * * *', expect.any(Function)); + expect(client.jobs).toHaveLength(1); +}); + +test('the scheduled job re-runs retention and automod sync', async () => { + const client = {jobs: []}; + let cron; + mockScheduleJob.mockImplementation((spec, cb) => { + cron = cb; + return 'job'; + }); + await handler.run(client); + mockEnforce.mockClear(); + mockSync.mockClear(); + await cron(); + expect(mockEnforce).toHaveBeenCalledTimes(1); + expect(mockSync).toHaveBeenCalledTimes(1); +}); diff --git a/tests/ping-protection/command.test.js b/tests/ping-protection/command.test.js new file mode 100644 index 00000000..09d309a1 --- /dev/null +++ b/tests/ping-protection/command.test.js @@ -0,0 +1,142 @@ +/* + * Tests for the /ping-protection command. + * + * run() routes to subcommands[group][sub] when a group is present, else to + * subcommands[sub]. The user.* subcommands build a payload via the matching + * generate* helper and reply ephemerally. listHandler renders the protected / + * whitelisted config as an embed, using the "none" fallback for empty lists. + */ +const mockHistory = jest.fn().mockResolvedValue({ + embeds: ['h'], + components: [] +}); +const mockActions = jest.fn().mockResolvedValue({ + embeds: ['a'], + components: [] +}); +const mockPanel = jest.fn().mockResolvedValue({ + embeds: ['p'], + components: [] +}); +jest.mock('../../modules/ping-protection/ping-protection', () => ({ + generateHistoryResponse: (...a) => mockHistory(...a), + generateActionsResponse: (...a) => mockActions(...a), + generateUserPanel: (...a) => mockPanel(...a) +})); + +const command = require('../../modules/ping-protection/commands/ping-protection'); + +function makeInteraction({ + group = null, + sub, + user, + config + } = {}) { + return { + options: { + getSubcommandGroup: jest.fn(() => group), + getSubcommand: jest.fn(() => sub), + getUser: jest.fn(() => user) + }, + client: { + strings: { + disableFooterTimestamp: true, + footer: 'f', + footerImgUrl: '' + }, + configurations: {'ping-protection': {configuration: config}} + }, + reply: jest.fn().mockResolvedValue() + }; +} + +beforeEach(() => { + mockHistory.mockClear(); + mockActions.mockClear(); + mockPanel.mockClear(); +}); + +describe('routing', () => { + test('routes user.history to generateHistoryResponse', async () => { + const interaction = makeInteraction({ + group: 'user', + sub: 'history', + user: {id: 'u1'} + }); + await command.run(interaction); + expect(mockHistory).toHaveBeenCalledWith(interaction.client, 'u1', 1); + expect(interaction.reply).toHaveBeenCalled(); + }); + + test('routes user.actions-history to generateActionsResponse', async () => { + const interaction = makeInteraction({ + group: 'user', + sub: 'actions-history', + user: {id: 'u1'} + }); + await command.run(interaction); + expect(mockActions).toHaveBeenCalledWith(interaction.client, 'u1', 1); + }); + + test('routes user.panel to generateUserPanel', async () => { + const user = {id: 'u1'}; + const interaction = makeInteraction({ + group: 'user', + sub: 'panel', + user + }); + await command.run(interaction); + expect(mockPanel).toHaveBeenCalledWith(interaction.client, user); + }); +}); + +describe('list subcommands', () => { + test('protected list renders users and roles', async () => { + const interaction = makeInteraction({ + group: 'list', + sub: 'protected', + config: { + protectedUsers: ['u1'], + protectedRoles: ['r1'] + } + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + const usersField = embed.fields.find(f => f.name.includes('field-protected-users')); + expect(usersField.value).toContain('<@u1>'); + const rolesField = embed.fields.find(f => f.name.includes('field-protected-roles')); + expect(rolesField.value).toContain('<@&r1>'); + }); + + test('protected list shows the "none" fallback for empty lists', async () => { + const interaction = makeInteraction({ + group: 'list', + sub: 'protected', + config: { + protectedUsers: [], + protectedRoles: [] + } + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + expect(embed.fields[0].value).toContain('ping-protection.list-none'); + }); + + test('whitelisted list renders roles, channels and users', async () => { + const interaction = makeInteraction({ + group: 'list', + sub: 'whitelisted', + config: { + ignoredRoles: ['r1'], + ignoredChannels: ['c1'], + ignoredUsers: ['u1'] + } + }); + await command.run(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + const values = embed.fields.map(f => f.value).join('|'); + expect(values).toContain('<@&r1>'); + expect(values).toContain('<#c1>'); + expect(values).toContain('<@u1>'); + }); +}); \ No newline at end of file diff --git a/tests/ping-protection/dataHelpers.test.js b/tests/ping-protection/dataHelpers.test.js new file mode 100644 index 00000000..3d9feefa --- /dev/null +++ b/tests/ping-protection/dataHelpers.test.js @@ -0,0 +1,282 @@ +/* + * Tests for ping-protection's data-layer helpers not covered elsewhere: + * - addPing: in-memory debounce + DB duplicate-window guard, automod widening + * the window to 5s, and the 'Blocked by AutoMod' messageUrl fallback. + * - getPingCountInWindow: count query with a day-based cutoff. + * - fetchPingHistory / fetchModHistory: pagination shape + graceful handling + * of a missing ModerationLog model and a thrown query. + * - leaver helpers: markUserAsLeft (upsert) / markUserAsRejoined (destroy) / + * getLeaverStatus (findByPk). + * - deleteAllUserData fans out to executeDataDeletion + logs. + * - enforceRetention prunes ping history, mod logs, and leaver data per config. + */ +jest.useFakeTimers(); +const pp = require('../../modules/ping-protection/ping-protection'); + +function makeClient({ + storage = {}, + configuration = {enableAutomod: false}, + models = {} + } = {}) { + return { + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn() + }, + configurations: { + 'ping-protection': { + configuration, + storage + } + }, + models: {'ping-protection': models} + }; +} + +describe('addPing', () => { + test('creates a ping history row when no duplicate exists', async () => { + const create = jest.fn().mockResolvedValue(); + const findOne = jest.fn().mockResolvedValue(null); + const client = makeClient({ + models: { + PingHistory: { + create, + findOne + } + } + }); + await pp.addPing(client, 'u1', 'http://msg', 't1', false); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'u1', + messageUrl: 'http://msg', + targetId: 't1', + isRole: false + })); + }); + + test('falls back to the AutoMod label when messageUrl is missing', async () => { + const create = jest.fn().mockResolvedValue(); + const client = makeClient({ + models: { + PingHistory: { + create, + findOne: jest.fn().mockResolvedValue(null) + } + } + }); + await pp.addPing(client, 'u2', null, 't1', true); + expect(create.mock.calls[0][0].messageUrl).toBe('Blocked by AutoMod'); + }); + + test('skips the DB write when a recent duplicate exists', async () => { + const create = jest.fn().mockResolvedValue(); + const client = makeClient({ + models: { + PingHistory: { + create, + findOne: jest.fn().mockResolvedValue({id: 1}) + } + } + }); + await pp.addPing(client, 'u3', 'url', 't1', false); + expect(create).not.toHaveBeenCalled(); + }); + + test('in-memory debounce suppresses a rapid second call for the same pair', async () => { + const create = jest.fn().mockResolvedValue(); + const findOne = jest.fn().mockResolvedValue(null); + const client = makeClient({ + models: { + PingHistory: { + create, + findOne + } + } + }); + await pp.addPing(client, 'dbU', 'url', 'dbT', false); + await pp.addPing(client, 'dbU', 'url', 'dbT', false); // within window -> debounced + expect(create).toHaveBeenCalledTimes(1); + // after the window the debounce key is released + jest.advanceTimersByTime(2000); + await pp.addPing(client, 'dbU', 'url', 'dbT', false); + expect(create).toHaveBeenCalledTimes(2); + }); +}); + +describe('getPingCountInWindow', () => { + test('counts pings newer than the day-based cutoff', async () => { + const count = jest.fn().mockResolvedValue(7); + const client = makeClient({models: {PingHistory: {count}}}); + const result = await pp.getPingCountInWindow(client, 'u1', 14); + expect(result).toBe(7); + const where = count.mock.calls[0][0].where; + expect(where.userId).toBe('u1'); + expect(where.createdAt).toBeDefined(); + }); +}); + +describe('fetchPingHistory', () => { + test('passes pagination and returns total + rows', async () => { + const findAndCountAll = jest.fn().mockResolvedValue({ + count: 12, + rows: [{id: 1}] + }); + const client = makeClient({models: {PingHistory: {findAndCountAll}}}); + const res = await pp.fetchPingHistory(client, 'u1', 3, 5); + expect(findAndCountAll.mock.calls[0][0]).toMatchObject({ + limit: 5, + offset: 10 + }); + expect(res).toEqual({ + total: 12, + history: [{id: 1}] + }); + }); +}); + +describe('fetchModHistory', () => { + test('returns empty when the ModerationLog model is missing', async () => { + const client = makeClient({models: {}}); + const res = await pp.fetchModHistory(client, 'u1'); + expect(res).toEqual({ + total: 0, + history: [] + }); + }); + + test('returns rows when the query succeeds', async () => { + const findAndCountAll = jest.fn().mockResolvedValue({ + count: 2, + rows: [{type: 'MUTE'}] + }); + const client = makeClient({models: {ModerationLog: {findAndCountAll}}}); + const res = await pp.fetchModHistory(client, 'u1', 1, 5); + expect(res).toEqual({ + total: 2, + history: [{type: 'MUTE'}] + }); + }); + + test('warns and returns empty when the query throws', async () => { + const findAndCountAll = jest.fn().mockRejectedValue(new Error('db down')); + const client = makeClient({models: {ModerationLog: {findAndCountAll}}}); + const res = await pp.fetchModHistory(client, 'u1'); + expect(res).toEqual({ + total: 0, + history: [] + }); + expect(client.logger.warn).toHaveBeenCalled(); + }); +}); + +describe('leaver helpers', () => { + test('markUserAsLeft upserts with a timestamp', async () => { + const upsert = jest.fn().mockResolvedValue(); + const client = makeClient({models: {LeaverData: {upsert}}}); + await pp.markUserAsLeft(client, 'u1'); + expect(upsert).toHaveBeenCalledWith(expect.objectContaining({userId: 'u1'})); + expect(upsert.mock.calls[0][0].leftAt).toBeInstanceOf(Date); + }); + + test('markUserAsRejoined destroys the leaver row', async () => { + const destroy = jest.fn().mockResolvedValue(); + const client = makeClient({models: {LeaverData: {destroy}}}); + await pp.markUserAsRejoined(client, 'u1'); + expect(destroy).toHaveBeenCalledWith({where: {userId: 'u1'}}); + }); + + test('getLeaverStatus reads by primary key', async () => { + const findByPk = jest.fn().mockResolvedValue({userId: 'u1'}); + const client = makeClient({models: {LeaverData: {findByPk}}}); + const res = await pp.getLeaverStatus(client, 'u1'); + expect(findByPk).toHaveBeenCalledWith('u1'); + expect(res).toEqual({userId: 'u1'}); + }); +}); + +describe('deleteAllUserData', () => { + test('wipes everything and logs', async () => { + const models = { + PingHistory: {destroy: jest.fn().mockResolvedValue()}, + ModerationLog: {destroy: jest.fn().mockResolvedValue()}, + LeaverData: {destroy: jest.fn().mockResolvedValue()} + }; + const client = makeClient({models}); + await pp.deleteAllUserData(client, 'u1'); + expect(models.PingHistory.destroy).toHaveBeenCalled(); + expect(models.ModerationLog.destroy).toHaveBeenCalled(); + expect(models.LeaverData.destroy).toHaveBeenCalled(); + expect(client.logger.info).toHaveBeenCalled(); + }); +}); + +describe('enforceRetention', () => { + test('does nothing without a storage config', async () => { + const client = makeClient({storage: null}); + await expect(pp.enforceRetention(client)).resolves.toBeUndefined(); + }); + + test('prunes ping history older than the retention window (bulk mode)', async () => { + const destroy = jest.fn().mockResolvedValue(); + const client = makeClient({ + storage: { + enablePingHistory: true, + pingHistoryRetention: 4, + deleteAllPingHistoryAfterTimeframe: false + }, + models: { + PingHistory: { + destroy, + findAll: jest.fn() + } + } + }); + await pp.enforceRetention(client); + expect(destroy).toHaveBeenCalledWith(expect.objectContaining({where: expect.objectContaining({createdAt: expect.anything()})})); + }); + + test('wipes all data for users with expired pings when configured', async () => { + const phDestroy = jest.fn().mockResolvedValue(); + const findAll = jest.fn().mockResolvedValue([{userId: 'a'}, {userId: 'b'}]); + const client = makeClient({ + storage: { + enablePingHistory: true, + deleteAllPingHistoryAfterTimeframe: true + }, + models: { + PingHistory: { + destroy: phDestroy, + findAll + } + } + }); + await pp.enforceRetention(client); + expect(phDestroy).toHaveBeenCalledWith({where: {userId: ['a', 'b']}}); + }); + + test('deletes expired leaver rows and their data', async () => { + const leaver = { + userId: 'gone', + destroy: jest.fn().mockResolvedValue() + }; + const models = { + PingHistory: {destroy: jest.fn().mockResolvedValue()}, + ModerationLog: {destroy: jest.fn().mockResolvedValue()}, + LeaverData: { + findAll: jest.fn().mockResolvedValue([leaver]), + destroy: jest.fn().mockResolvedValue() + } + }; + const client = makeClient({ + storage: { + enableLeaverDataRetention: true, + leaverRetention: 1 + }, + models + }); + await pp.enforceRetention(client); + expect(leaver.destroy).toHaveBeenCalled(); + expect(client.logger.info).toHaveBeenCalled(); // deleteAllUserData logged + }); +}); \ No newline at end of file diff --git a/tests/ping-protection/interactionCreate.test.js b/tests/ping-protection/interactionCreate.test.js new file mode 100644 index 00000000..fe20b630 --- /dev/null +++ b/tests/ping-protection/interactionCreate.test.js @@ -0,0 +1,212 @@ +/* + * Tests for ping-protection's interactionCreate panel handler. + * + * Covers: + * - botReady guard + * - panel-menu select: admin gate, unknown user, and routing each selection + * (overview/history/actions/deletion) to its generator + interaction.update + * - delete-menu select: 'back' returns the panel; an active cooldown blocks; a + * real selection opens the confirm modal + * - del-confirm modal submit: wrong phrase rejected; correct phrase runs a + * partial deletion, sets the cooldown, and confirms + * - hist-page / mod-page button pagination routes to the right generator + * + * Generators and cooldown helpers are mocked. + */ +const mockG = { + generateHistoryResponse: jest.fn().mockResolvedValue({embeds: ['h']}), + generateActionsResponse: jest.fn().mockResolvedValue({embeds: ['a']}), + generateUserPanel: jest.fn().mockResolvedValue({embeds: ['panel']}), + generatePanelHistory: jest.fn().mockResolvedValue({embeds: ['ph']}), + generatePanelActions: jest.fn().mockResolvedValue({embeds: ['pa']}), + generatePanelDeletion: jest.fn().mockResolvedValue({embeds: ['pd']}), + executeDataDeletion: jest.fn().mockResolvedValue(), + getDeletionCooldown: jest.fn().mockResolvedValue(null), + setDeletionCooldown: jest.fn().mockResolvedValue(new Date(Date.now() + 1000)), + getDeletionTypeLocaleKey: jest.fn(() => 'del-type-pings') +}; +jest.mock('../../modules/ping-protection/ping-protection', () => mockG); + +const handler = require('../../modules/ping-protection/events/interactionCreate'); + +function makeClient({ + user = { + id: 'target', + username: 'T', + tag: 'T#1' + } + } = {}) { + return { + botReadyAt: Date.now(), + strings: { + disableFooterTimestamp: true, + footer: 'f', + footerImgUrl: '' + }, + logger: {info: jest.fn()}, + users: {fetch: jest.fn().mockResolvedValue(user)} + }; +} + +function baseInteraction(over = {}) { + return { + member: {permissions: {has: () => true}}, + isStringSelectMenu: () => false, + isModalSubmit: () => false, + isButton: () => false, + reply: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue(), + showModal: jest.fn().mockResolvedValue(), + ...over + }; +} + +beforeEach(() => { + Object.values(mockG).forEach(fn => fn.mockClear && fn.mockClear()); + mockG.getDeletionCooldown.mockResolvedValue(null); +}); + +test('returns immediately before botReady', async () => { + const client = makeClient(); + client.botReadyAt = undefined; + const interaction = baseInteraction({ + isStringSelectMenu: () => true, + customId: 'ping-protection_panel-menu_target', + values: ['overview'] + }); + await handler.run(client, interaction); + expect(mockG.generateUserPanel).not.toHaveBeenCalled(); +}); + +describe('panel-menu select', () => { + function menuInteraction(selection, isAdmin = true) { + return baseInteraction({ + member: {permissions: {has: () => isAdmin}}, + isStringSelectMenu: () => true, + customId: 'ping-protection_panel-menu_target', + values: [selection] + }); + } + + test('blocks non-admins', async () => { + const interaction = menuInteraction('overview', false); + await handler.run(makeClient(), interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('no-permission'); + expect(mockG.generateUserPanel).not.toHaveBeenCalled(); + }); + + test('replies no-data when the user cannot be fetched', async () => { + const client = makeClient(); + client.users.fetch.mockResolvedValue(null); + const interaction = menuInteraction('overview'); + await handler.run(client, interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('no-data-found'); + }); + + test.each([ + ['overview', 'generateUserPanel'], + ['history', 'generatePanelHistory'], + ['actions', 'generatePanelActions'], + ['deletion', 'generatePanelDeletion'] + ])('routes %s to %s and updates', async (selection, fnName) => { + const interaction = menuInteraction(selection); + await handler.run(makeClient(), interaction); + expect(mockG[fnName]).toHaveBeenCalled(); + expect(interaction.update).toHaveBeenCalled(); + }); +}); + +describe('delete-menu select', () => { + function delInteraction(selection) { + return baseInteraction({ + member: {permissions: {has: () => true}}, + isStringSelectMenu: () => true, + customId: 'ping-protection_delete-menu_target', + values: [selection] + }); + } + + test('back returns the overview panel', async () => { + const interaction = delInteraction('back'); + await handler.run(makeClient(), interaction); + expect(mockG.generateUserPanel).toHaveBeenCalled(); + expect(interaction.update).toHaveBeenCalled(); + }); + + test('an active cooldown blocks and replies', async () => { + mockG.getDeletionCooldown.mockResolvedValue({ + blockedUntil: new Date(Date.now() + 100000), + lastDeletionType: 'del_ping_history' + }); + const interaction = delInteraction('del_ping_history'); + await handler.run(makeClient(), interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('err-del-cooldown'); + expect(interaction.showModal).not.toHaveBeenCalled(); + }); + + test('a real selection opens the confirmation modal', async () => { + const interaction = delInteraction('del_ping_history'); + await handler.run(makeClient(), interaction); + expect(interaction.showModal).toHaveBeenCalled(); + }); +}); + +describe('del-confirm modal submit', () => { + function modalInteraction(value, selection = 'del_ping_history') { + return baseInteraction({ + member: {permissions: {has: () => true}}, + isModalSubmit: () => true, + customId: `ping-protection_del-confirm_target_${selection}`, + user: {id: 'admin1'}, + message: {edit: jest.fn().mockResolvedValue()}, + fields: {getTextInputValue: jest.fn(() => value)} + }); + } + + test('rejects a wrong confirmation phrase', async () => { + const interaction = modalInteraction('not the phrase'); + await handler.run(makeClient(), interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('modal-failed'); + expect(mockG.executeDataDeletion).not.toHaveBeenCalled(); + }); + + test('runs a partial deletion and sets the cooldown on the correct phrase', async () => { + // the stub localize returns "ping-protection.modal-phrase"; confirm must equal it + const interaction = modalInteraction('ping-protection.modal-phrase'); + await handler.run(makeClient(), interaction); + expect(mockG.executeDataDeletion).toHaveBeenCalledWith(expect.anything(), 'target', 'del_ping_history'); + expect(mockG.setDeletionCooldown).toHaveBeenCalledWith(expect.anything(), 'target', 'del_ping_history', 'admin1'); + expect(interaction.reply.mock.calls[0][0].content).toContain('succ-del-tgt'); + }); +}); + +describe('button pagination', () => { + test('hist-page routes to generateHistoryResponse with the parsed page', async () => { + const interaction = baseInteraction({ + isButton: () => true, + customId: 'ping-protection_hist-page_target_3' + }); + await handler.run(makeClient(), interaction); + expect(mockG.generateHistoryResponse).toHaveBeenCalledWith(expect.anything(), 'target', 3); + expect(interaction.update).toHaveBeenCalled(); + }); + + test('mod-page routes to generateActionsResponse with the parsed page', async () => { + const interaction = baseInteraction({ + isButton: () => true, + customId: 'ping-protection_mod-page_target_2' + }); + await handler.run(makeClient(), interaction); + expect(mockG.generateActionsResponse).toHaveBeenCalledWith(expect.anything(), 'target', 2); + }); + + test('panel-hist routes to generatePanelHistory', async () => { + const interaction = baseInteraction({ + isButton: () => true, + customId: 'ping-protection_panel-hist_target_2' + }); + await handler.run(makeClient(), interaction); + expect(mockG.generatePanelHistory).toHaveBeenCalled(); + expect(interaction.update).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/ping-protection/memberEvents.test.js b/tests/ping-protection/memberEvents.test.js new file mode 100644 index 00000000..ec94de7b --- /dev/null +++ b/tests/ping-protection/memberEvents.test.js @@ -0,0 +1,81 @@ +/* + * Tests for ping-protection's guildMemberAdd / guildMemberRemove handlers. + * + * Remove: with leaver retention enabled -> markUserAsLeft; otherwise -> wipe data. + * Add: rejoin clears the leaver flag. Both guard on botReady + matching guild. + */ +const mockMarkLeft = jest.fn().mockResolvedValue(); +const mockMarkRejoined = jest.fn().mockResolvedValue(); +const mockDeleteAll = jest.fn().mockResolvedValue(); +jest.mock('../../modules/ping-protection/ping-protection', () => ({ + markUserAsLeft: (...a) => mockMarkLeft(...a), + markUserAsRejoined: (...a) => mockMarkRejoined(...a), + deleteAllUserData: (...a) => mockDeleteAll(...a) +})); + +const addHandler = require('../../modules/ping-protection/events/guildMemberAdd'); +const removeHandler = require('../../modules/ping-protection/events/guildMemberRemove'); + +function makeClient({ + ready = true, + storage = {} + } = {}) { + return { + botReadyAt: ready ? Date.now() : undefined, + guildID: 'g1', + configurations: {'ping-protection': {storage}} + }; +} + +function makeMember(guildID = 'g1', id = 'u1') { + return { + id, + guild: {id: guildID} + }; +} + +beforeEach(() => { + mockMarkLeft.mockClear(); + mockMarkRejoined.mockClear(); + mockDeleteAll.mockClear(); +}); + +describe('guildMemberRemove', () => { + test('marks the user as left when leaver retention is enabled', async () => { + const client = makeClient({storage: {enableLeaverDataRetention: true}}); + await removeHandler.run(client, makeMember()); + expect(mockMarkLeft).toHaveBeenCalledWith(client, 'u1'); + expect(mockDeleteAll).not.toHaveBeenCalled(); + }); + + test('deletes all data when leaver retention is disabled', async () => { + const client = makeClient({storage: {enableLeaverDataRetention: false}}); + await removeHandler.run(client, makeMember()); + expect(mockDeleteAll).toHaveBeenCalledWith(client, 'u1'); + expect(mockMarkLeft).not.toHaveBeenCalled(); + }); + + test('ignores other guilds and pre-ready events', async () => { + await removeHandler.run(makeClient({ + ready: false, + storage: {} + }), makeMember()); + await removeHandler.run(makeClient({storage: {}}), makeMember('other')); + expect(mockMarkLeft).not.toHaveBeenCalled(); + expect(mockDeleteAll).not.toHaveBeenCalled(); + }); +}); + +describe('guildMemberAdd', () => { + test('clears the leaver flag on rejoin', async () => { + const client = makeClient(); + await addHandler.run(client, makeMember()); + expect(mockMarkRejoined).toHaveBeenCalledWith(client, 'u1'); + }); + + test('ignores other guilds and pre-ready events', async () => { + await addHandler.run(makeClient({ready: false}), makeMember()); + await addHandler.run(makeClient(), makeMember('other')); + expect(mockMarkRejoined).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/ping-protection/messageCreate.test.js b/tests/ping-protection/messageCreate.test.js new file mode 100644 index 00000000..0ce59ac9 --- /dev/null +++ b/tests/ping-protection/messageCreate.test.js @@ -0,0 +1,257 @@ +/* + * Tests for ping-protection's messageCreate handler. + * + * Covers the guard chain (botReady, guild match, bots, whitelisted channel, + * ignored users/roles), protected-target detection (protected user vs protected + * role mention, protectAllUsersWithProtectedRole), the reply-ping allowance, the + * self-ping "Ignored" short circuit, and the warn + processPing dispatch for a + * genuine protected ping. + */ +const mockProcessPing = jest.fn().mockResolvedValue(); +const mockSendWarning = jest.fn().mockResolvedValue(); +const mockIsWhitelisted = jest.fn(() => false); +jest.mock('../../modules/ping-protection/ping-protection', () => ({ + processPing: (...a) => mockProcessPing(...a), + sendPingWarning: (...a) => mockSendWarning(...a), + isWhitelistedChannel: (...a) => mockIsWhitelisted(...a) +})); + +const handler = require('../../modules/ping-protection/events/messageCreate'); + +function makeCollection(items) { + const map = new Map(items.map(i => [i.id, i])); + return { + size: map.size, + get: (id) => map.get(id), + forEach: (cb) => map.forEach(cb), + some: (fn) => [...map.values()].some(fn), + find: (fn) => [...map.values()].find(fn) + }; +} + +function makeConfig(over = {}) { + return { + ignoredUsers: [], + ignoredRoles: [], + protectedRoles: [], + protectedUsers: [], + protectAllUsersWithProtectedRole: false, + allowReplyPings: false, + selfPingConfiguration: 'Off', + ...over + }; +} + +function makeClient(config) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + configurations: {'ping-protection': {configuration: config}} + }; +} + +function makeMessage({ + authorId = 'pinger', + bot = false, + users = [], + roles = [], + repliedUser = null, + members = [], + content = '', + memberRoles = [] + } = {}) { + const memberCollection = makeCollection(members); + return { + guild: { + id: 'g1', + members: {fetch: jest.fn().mockResolvedValue({id: authorId})} + }, + author: { + id: authorId, + bot + }, + url: 'http://msg', + content, + channel: { + id: 'c1', + send: jest.fn() + }, + member: {roles: {cache: makeCollection(memberRoles)}}, + mentions: { + roles: makeCollection(roles), + users: makeCollection(users), + members: memberCollection, + repliedUser + }, + reply: jest.fn().mockResolvedValue() + }; +} + +beforeEach(() => { + mockProcessPing.mockClear(); + mockSendWarning.mockClear(); + mockIsWhitelisted.mockClear(); + mockIsWhitelisted.mockReturnValue(false); +}); + +describe('guards', () => { + test('ignores messages before botReady', async () => { + const client = makeClient(makeConfig()); + client.botReadyAt = undefined; + await handler.run(client, makeMessage()); + expect(mockProcessPing).not.toHaveBeenCalled(); + }); + + test('ignores messages from other guilds', async () => { + const client = makeClient(makeConfig()); + const msg = makeMessage(); + msg.guild.id = 'other'; + await handler.run(client, msg); + expect(mockProcessPing).not.toHaveBeenCalled(); + }); + + test('ignores bot authors', async () => { + const client = makeClient(makeConfig({protectedUsers: ['victim']})); + await handler.run(client, makeMessage({ + bot: true, + users: [{id: 'victim'}] + })); + expect(mockProcessPing).not.toHaveBeenCalled(); + }); + + test('ignores whitelisted channels', async () => { + mockIsWhitelisted.mockReturnValue(true); + const client = makeClient(makeConfig({protectedUsers: ['victim']})); + await handler.run(client, makeMessage({users: [{id: 'victim'}]})); + expect(mockProcessPing).not.toHaveBeenCalled(); + }); + + test('ignores configured ignored users', async () => { + const client = makeClient(makeConfig({ + ignoredUsers: ['pinger'], + protectedUsers: ['victim'] + })); + await handler.run(client, makeMessage({users: [{id: 'victim'}]})); + expect(mockProcessPing).not.toHaveBeenCalled(); + }); + + test('ignores authors holding an ignored role', async () => { + const client = makeClient(makeConfig({ + ignoredRoles: ['roleI'], + protectedUsers: ['victim'] + })); + await handler.run(client, makeMessage({ + users: [{id: 'victim'}], + memberRoles: [{id: 'roleI'}] + })); + expect(mockProcessPing).not.toHaveBeenCalled(); + }); + + test('does nothing when no protected entity was pinged', async () => { + const client = makeClient(makeConfig({protectedUsers: ['victim']})); + await handler.run(client, makeMessage({users: [{id: 'random'}]})); + expect(mockProcessPing).not.toHaveBeenCalled(); + }); +}); + +describe('protected ping dispatch', () => { + test('warns and processes a protected-user ping', async () => { + const client = makeClient(makeConfig({protectedUsers: ['victim']})); + const victimUser = { + id: 'victim', + username: 'Victim' + }; + const msg = makeMessage({users: [victimUser]}); + await handler.run(client, msg); + expect(mockSendWarning).toHaveBeenCalledWith(client, msg, victimUser, expect.any(Object)); + expect(mockProcessPing).toHaveBeenCalledWith( + client, 'pinger', 'victim', false, 'http://msg', msg.channel, msg.member + ); + }); + + test('treats a protected-role mention as isRole=true', async () => { + const client = makeClient(makeConfig({protectedRoles: ['roleP']})); + const role = {id: 'roleP'}; // no username -> role + const msg = makeMessage({roles: [role]}); + await handler.run(client, msg); + expect(mockProcessPing).toHaveBeenCalledWith( + client, 'pinger', 'roleP', true, 'http://msg', msg.channel, msg.member + ); + }); + + test('protectAllUsersWithProtectedRole catches a member with a protected role', async () => { + const client = makeClient(makeConfig({ + protectAllUsersWithProtectedRole: true, + protectedRoles: ['roleP'] + })); + const victimUser = { + id: 'victim', + username: 'V' + }; + const victimMember = {roles: {cache: makeCollection([{id: 'roleP'}])}}; + const msg = makeMessage({ + users: [victimUser], + members: [{id: 'victim', ...victimMember}] + }); + await handler.run(client, msg); + expect(mockProcessPing).toHaveBeenCalled(); + }); +}); + +describe('self-ping', () => { + test('does nothing when selfPingConfiguration is Ignored', async () => { + const client = makeClient(makeConfig({ + protectedUsers: ['pinger'], + selfPingConfiguration: 'Ignored' + })); + const msg = makeMessage({ + authorId: 'pinger', + users: [{ + id: 'pinger', + username: 'Me' + }] + }); + await handler.run(client, msg); + expect(mockSendWarning).not.toHaveBeenCalled(); + expect(mockProcessPing).not.toHaveBeenCalled(); + }); +}); + +describe('reply pings', () => { + test('does not punish an auto reply-ping of a protected user when not manually typed', async () => { + const client = makeClient(makeConfig({ + protectedUsers: ['victim'], + allowReplyPings: true + })); + const victimUser = { + id: 'victim', + username: 'V' + }; + // replied user is the protected victim, content has no manual <@victim> + const msg = makeMessage({ + users: [victimUser], + repliedUser: {id: 'victim'}, + content: 'just replying' + }); + await handler.run(client, msg); + expect(mockProcessPing).not.toHaveBeenCalled(); + }); + + test('still punishes when the protected user is also manually pinged in content', async () => { + const client = makeClient(makeConfig({ + protectedUsers: ['victim'], + allowReplyPings: true + })); + const victimUser = { + id: 'victim', + username: 'V' + }; + const msg = makeMessage({ + users: [victimUser], + repliedUser: {id: 'victim'}, + content: 'hey <@victim> look' + }); + await handler.run(client, msg); + expect(mockProcessPing).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/ping-protection/pingProtectionLogic.test.js b/tests/ping-protection/pingProtectionLogic.test.js new file mode 100644 index 00000000..efe56fae --- /dev/null +++ b/tests/ping-protection/pingProtectionLogic.test.js @@ -0,0 +1,229 @@ +/* + * Unit tests for ping-protection's core logic helpers. + * + * Covers: + * - isWhitelistedChannel: channel + parent matching against ignoredChannels. + * - getSafeChannelId: array/string/garbage normalisation + length guard. + * - getRequiredPingCountForMember: base count fallbacks, role-based + * thresholds, exempt (0) handling, and highest-role selection. + * - getDeletionTypeLocaleKey: data-type -> locale key mapping. + * - setDeletionCooldown: 24h (partial) vs 168h (full) window via upsert. + * - getDeletionCooldown: expiry cleanup vs active cooldown. + */ +const pp = require('../../modules/ping-protection/ping-protection'); + +describe('isWhitelistedChannel', () => { + const cfg = {ignoredChannels: ['100', '200']}; + + test('false when channel is null or config missing list', () => { + expect(pp.isWhitelistedChannel(cfg, null)).toBe(false); + expect(pp.isWhitelistedChannel({}, {id: '100'})).toBe(false); + expect(pp.isWhitelistedChannel({ignoredChannels: []}, {id: '100'})).toBe(false); + }); + + test('matches by channel id', () => { + expect(pp.isWhitelistedChannel(cfg, {id: '100'})).toBe(true); + expect(pp.isWhitelistedChannel(cfg, {id: '999'})).toBe(false); + }); + + test('matches by parent (category) id', () => { + expect(pp.isWhitelistedChannel(cfg, { + id: '999', + parentId: '200' + })).toBe(true); + }); + + test('numeric ids in config still match string channel ids', () => { + expect(pp.isWhitelistedChannel({ignoredChannels: [100]}, {id: '100'})).toBe(true); + }); +}); + +describe('getSafeChannelId', () => { + test('returns null for falsy / empty', () => { + expect(pp.getSafeChannelId(null)).toBeNull(); + expect(pp.getSafeChannelId([])).toBeNull(); + }); + + test('extracts first element of an array', () => { + expect(pp.getSafeChannelId(['123456789'])).toBe('123456789'); + }); + + test('accepts a plain string', () => { + expect(pp.getSafeChannelId('123456789')).toBe('123456789'); + }); + + test('rejects ids that are too short (<= 5 chars)', () => { + expect(pp.getSafeChannelId('123')).toBeNull(); + expect(pp.getSafeChannelId(['12'])).toBeNull(); + }); + + test('returns null for a bare number (only arrays/strings are accepted)', () => { + expect(pp.getSafeChannelId(123456789)).toBeNull(); + }); + + test('coerces a numeric array element to string', () => { + expect(pp.getSafeChannelId([123456789])).toBe('123456789'); + }); +}); + +describe('getRequiredPingCountForMember', () => { + function memberWithRoles(roles) { + return { + roles: { + cache: { + filter(fn) { + const kept = roles.filter(fn); + return makeCollection(kept); + } + } + } + }; + } + + function makeCollection(arr) { + return { + size: arr.length, + sort(cmp) { + return makeCollection([...arr].sort(cmp)); + }, + first() { + return arr[0]; + }, + values() { + return arr.values(); + } + }; + } + + test('returns base count when thresholds disabled', () => { + const rule = { + pingsCount: 5, + enableRolePingThresholds: false + }; + expect(pp.getRequiredPingCountForMember(rule, null)).toBe(5); + }); + + test('falls back through pingsCountAdvanced / pingsCountBasic', () => { + expect(pp.getRequiredPingCountForMember({pingsCountAdvanced: 7}, null)).toBe(7); + expect(pp.getRequiredPingCountForMember({pingsCountBasic: 3}, null)).toBe(3); + }); + + test('returns null when no usable base count', () => { + expect(pp.getRequiredPingCountForMember({}, null)).toBeNull(); + expect(pp.getRequiredPingCountForMember({pingsCount: 'nope'}, null)).toBeNull(); + }); + + test('uses base count when member has no matching role', () => { + const rule = { + pingsCount: 5, + enableRolePingThresholds: true, + rolePingThresholds: {roleX: 2} + }; + const member = memberWithRoles([{ + id: 'roleY', + position: 1 + }]); + expect(pp.getRequiredPingCountForMember(rule, member)).toBe(5); + }); + + test('returns EXEMPT when a matching role maps to 0', () => { + const rule = { + pingsCount: 5, + enableRolePingThresholds: true, + rolePingThresholds: {roleA: 0} + }; + const member = memberWithRoles([{ + id: 'roleA', + position: 1 + }]); + expect(pp.getRequiredPingCountForMember(rule, member)).toBe(pp.EXEMPT_THRESHOLD); + }); + + test('uses highest-position matching role threshold', () => { + const rule = { + pingsCount: 5, + enableRolePingThresholds: true, + rolePingThresholds: { + low: 10, + high: 2 + } + }; + const member = memberWithRoles([ + { + id: 'low', + position: 1 + }, + { + id: 'high', + position: 9 + } + ]); + expect(pp.getRequiredPingCountForMember(rule, member)).toBe(2); + }); +}); + +describe('getDeletionTypeLocaleKey', () => { + test('maps each known data type', () => { + expect(pp.getDeletionTypeLocaleKey('del_ping_history')).toBe('del-type-pings'); + expect(pp.getDeletionTypeLocaleKey('del_moderation_history')).toBe('del-type-actions'); + expect(pp.getDeletionTypeLocaleKey('del_all')).toBe('del-type-all'); + }); + + test('falls back to unknown', () => { + expect(pp.getDeletionTypeLocaleKey('something-else')).toBe('del-type-unknown'); + }); +}); + +describe('deletion cooldown windows', () => { + function clientWithCooldownModel(impl) { + return {models: {'ping-protection': {DeletionCooldown: impl}}}; + } + + test('setDeletionCooldown uses 24h for partial deletions', async () => { + const upsert = jest.fn().mockResolvedValue(); + const client = clientWithCooldownModel({upsert}); + const before = Date.now(); + const blockedUntil = await pp.setDeletionCooldown(client, 'u1', 'del_ping_history', 'mod1'); + const diffHours = (blockedUntil.getTime() - before) / 3600000; + expect(diffHours).toBeGreaterThan(23.9); + expect(diffHours).toBeLessThan(24.1); + expect(upsert).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'u1', + lastDeletionType: 'del_ping_history', + lastDeletedBy: 'mod1' + })); + }); + + test('setDeletionCooldown uses 168h (7d) for del_all', async () => { + const upsert = jest.fn().mockResolvedValue(); + const client = clientWithCooldownModel({upsert}); + const before = Date.now(); + const blockedUntil = await pp.setDeletionCooldown(client, 'u1', 'del_all'); + const diffHours = (blockedUntil.getTime() - before) / 3600000; + expect(diffHours).toBeGreaterThan(167.9); + expect(diffHours).toBeLessThan(168.1); + }); + + test('getDeletionCooldown destroys & returns null when expired', async () => { + const destroy = jest.fn().mockResolvedValue(); + const expired = { + blockedUntil: new Date(Date.now() - 1000), + destroy + }; + const client = clientWithCooldownModel({findByPk: jest.fn().mockResolvedValue(expired)}); + const result = await pp.getDeletionCooldown(client, 'u1'); + expect(result).toBeNull(); + expect(destroy).toHaveBeenCalled(); + }); + + test('getDeletionCooldown returns the active cooldown', async () => { + const active = { + blockedUntil: new Date(Date.now() + 60000), + destroy: jest.fn() + }; + const client = clientWithCooldownModel({findByPk: jest.fn().mockResolvedValue(active)}); + const result = await pp.getDeletionCooldown(client, 'u1'); + expect(result).toBe(active); + expect(active.destroy).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/ping-protection/processPing.test.js b/tests/ping-protection/processPing.test.js new file mode 100644 index 00000000..5787dbe5 --- /dev/null +++ b/tests/ping-protection/processPing.test.js @@ -0,0 +1,197 @@ +/* + * Behavioural tests for ping-protection's processPing / executeAction / + * executeDataDeletion. + * + * processPing decides whether a member crosses a moderation rule's ping + * threshold within a timeframe and, if so, punishes them (unless a recent + * action already exists). executeAction enforces role-hierarchy safety and + * dispatches MUTE/KICK. executeDataDeletion fans out destroy() calls based on + * the requested data type. + */ +const pp = require('../../modules/ping-protection/ping-protection'); + +function makeLogger() { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn() + }; +} + +function makeClient({ + moderation = [], + storage = {enablePingHistory: false}, + pingCount = 0, + recentLog = null + } = {}) { + return { + user: {id: 'bot'}, + logger: makeLogger(), + strings: { + disableFooterTimestamp: true, + footer: 'f', + footerImgUrl: '' + }, + configurations: { + 'ping-protection': { + configuration: {enableAutomod: false}, + storage, + moderation + } + }, + models: { + 'ping-protection': { + PingHistory: { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue(), + count: jest.fn().mockResolvedValue(pingCount), + destroy: jest.fn().mockResolvedValue() + }, + ModerationLog: { + findOne: jest.fn().mockResolvedValue(recentLog), + create: jest.fn().mockResolvedValue(), + destroy: jest.fn().mockResolvedValue() + }, + LeaverData: {destroy: jest.fn().mockResolvedValue()} + } + } + }; +} + +function makeMember({mutable = true} = {}) { + const member = { + id: 'victim', + user: { + id: 'victim', + tag: 'Victim#0001' + }, + toString: () => '<@victim>', + roles: {highest: {position: mutable ? 1 : 5}}, + timeout: jest.fn().mockResolvedValue(), + kick: jest.fn().mockResolvedValue(), + guild: { + members: { + fetch: jest.fn().mockResolvedValue({roles: {highest: {position: 5}}}) + } + } + }; + return member; +} + +describe('processPing', () => { + test('does nothing when there are no moderation rules', async () => { + const client = makeClient({moderation: []}); + const member = makeMember(); + await pp.processPing(client, 'victim', 'target', false, 'url', null, member); + expect(member.timeout).not.toHaveBeenCalled(); + expect(client.models['ping-protection'].ModerationLog.create).not.toHaveBeenCalled(); + }); + + test('punishes (MUTE) once the ping count meets the rule threshold', async () => { + const client = makeClient({ + moderation: [{ + actionType: 'MUTE', + muteDuration: 10, + pingsCount: 3 + }], + pingCount: 3 + }); + const member = makeMember(); + await pp.processPing(client, 'victim', 'target', false, 'url', null, member); + expect(member.timeout).toHaveBeenCalledWith(10 * 60000, expect.any(String)); + expect(client.models['ping-protection'].ModerationLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + victimID: 'victim', + type: 'MUTE', + actionDuration: 10 + }) + ); + }); + + test('does NOT punish when below threshold', async () => { + const client = makeClient({ + moderation: [{ + actionType: 'MUTE', + muteDuration: 10, + pingsCount: 5 + }], + pingCount: 2 + }); + const member = makeMember(); + await pp.processPing(client, 'victim', 'target', false, 'url', null, member); + expect(member.timeout).not.toHaveBeenCalled(); + }); + + test('skips punishment when a recent moderation log exists (anti-double-punish)', async () => { + const client = makeClient({ + moderation: [{ + actionType: 'MUTE', + muteDuration: 10, + pingsCount: 1 + }], + pingCount: 5, + recentLog: {id: 1} + }); + const member = makeMember(); + await pp.processPing(client, 'victim', 'target', false, 'url', null, member); + expect(member.timeout).not.toHaveBeenCalled(); + }); + + test('records ping history only when enabled in storage', async () => { + const client = makeClient({ + moderation: [], + storage: {enablePingHistory: true} + }); + await pp.processPing(client, 'victim', 'target', false, 'url', null, makeMember()); + expect(client.models['ping-protection'].PingHistory.create).toHaveBeenCalled(); + }); +}); + +describe('executeAction role hierarchy guard', () => { + test('refuses to act when the target outranks the bot', async () => { + const client = makeClient(); + const member = makeMember({mutable: false}); // member position 5, bot fetched as 5 + const ok = await pp.executeAction(client, member, { + actionType: 'MUTE', + muteDuration: 5 + }, 'reason', {}, null, {}); + expect(ok).toBe(false); + expect(member.timeout).not.toHaveBeenCalled(); + }); + + test('performs a KICK and reports success', async () => { + const client = makeClient(); + const member = makeMember(); + const ok = await pp.executeAction(client, member, {actionType: 'KICK'}, 'reason', {}, null, {}); + expect(ok).toBe(true); + expect(member.kick).toHaveBeenCalledWith('reason'); + }); + + test('returns false for an unknown action type', async () => { + const client = makeClient(); + const member = makeMember(); + const ok = await pp.executeAction(client, member, {actionType: 'WARN'}, 'reason', {}, null, {}); + expect(ok).toBe(false); + }); +}); + +describe('executeDataDeletion', () => { + test('del_ping_history only wipes ping history', async () => { + const client = makeClient(); + const models = client.models['ping-protection']; + await pp.executeDataDeletion(client, 'u1', 'del_ping_history'); + expect(models.PingHistory.destroy).toHaveBeenCalledWith({where: {userId: 'u1'}}); + expect(models.ModerationLog.destroy).not.toHaveBeenCalled(); + expect(models.LeaverData.destroy).not.toHaveBeenCalled(); + }); + + test('del_all wipes pings, mod logs, and leaver data', async () => { + const client = makeClient(); + const models = client.models['ping-protection']; + await pp.executeDataDeletion(client, 'u1', 'del_all'); + expect(models.PingHistory.destroy).toHaveBeenCalled(); + expect(models.ModerationLog.destroy).toHaveBeenCalledWith({where: {victimID: 'u1'}}); + expect(models.LeaverData.destroy).toHaveBeenCalledWith({where: {userId: 'u1'}}); + }); +}); \ No newline at end of file diff --git a/tests/ping-protection/render.test.js b/tests/ping-protection/render.test.js new file mode 100644 index 00000000..d66e61be --- /dev/null +++ b/tests/ping-protection/render.test.js @@ -0,0 +1,320 @@ +/* + * Render/embed-building tests for ping-protection.js generators and AutoMod sync. + * + * generateUserPanel / generatePanelHistory / generatePanelActions / + * generatePanelDeletion / generateHistoryResponse / generateActionsResponse all + * return {embeds:[...], components:[...]} JSON. We assert key branches: + * - history disabled vs empty vs populated + * - leaver warning prefix + * - deletion panel cooldown notice + * - pagination button disabled states + * sendPingWarning falls back from reply -> channel.send -> null on failure. + * syncNativeAutoMod deletes the rule when automod is disabled and creates/edits + * it with the protected keywords when enabled. + */ +const pp = require('../../modules/ping-protection/ping-protection'); + +function baseClient({ + storage = {}, + moderation = [], + models = {}, + users + } = {}) { + return { + strings: { + disableFooterTimestamp: true, + footer: 'f', + footerImgUrl: '' + }, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn() + }, + users: users || { + fetch: jest.fn().mockResolvedValue({ + username: 'U', + displayAvatarURL: () => null + }) + }, + configurations: { + 'ping-protection': { + storage, + moderation + } + }, + models: {'ping-protection': models} + }; +} + +function userObj(over = {}) { + return { + id: 'u1', + tag: 'User#1', + username: 'User', + toString: () => '<@u1>', + displayAvatarURL: () => null, + ...over + }; +} + +describe('generateUserPanel', () => { + test('summarises ping + mod counts with the overview menu', async () => { + const client = baseClient({ + storage: {pingHistoryRetention: 8}, + models: { + PingHistory: {count: jest.fn().mockResolvedValue(4)}, + ModerationLog: { + findAndCountAll: jest.fn().mockResolvedValue({ + count: 2, + rows: [] + }) + } + } + }); + const res = await pp.generateUserPanel(client, userObj()); + expect(res.embeds).toHaveLength(1); + expect(res.components).toHaveLength(1); + // the quick-stats field embeds both counts + const field = res.embeds[0].fields[0]; + expect(field.value).toContain('p=4'); + expect(field.value).toContain('m=2'); + }); +}); + +describe('generateHistoryResponse', () => { + test('shows the disabled message when ping history is off', async () => { + const client = baseClient({ + storage: {enablePingHistory: false}, + models: {LeaverData: {findByPk: jest.fn().mockResolvedValue(null)}} + }); + const res = await pp.generateHistoryResponse(client, 'u1', 1); + expect(res.embeds[0].description).toContain('history-disabled'); + }); + + test('renders entries and a leaver warning when present', async () => { + const client = baseClient({ + storage: {enablePingHistory: true}, + models: { + PingHistory: { + findAndCountAll: jest.fn().mockResolvedValue({ + count: 1, + rows: [{ + createdAt: new Date(), + targetId: 't1', + isRole: false, + messageUrl: 'http://m' + }] + }) + }, + LeaverData: {findByPk: jest.fn().mockResolvedValue({leftAt: new Date()})} + } + }); + const res = await pp.generateHistoryResponse(client, 'u1', 1); + expect(res.embeds[0].description).toContain('leaver-warning'); + expect(res.embeds[0].description).toContain('list-entry-text'); + }); + + test('back button disabled on page 1, next disabled when only one page', async () => { + const client = baseClient({ + storage: {enablePingHistory: true}, + models: { + PingHistory: { + findAndCountAll: jest.fn().mockResolvedValue({ + count: 0, + rows: [] + }) + }, + LeaverData: {findByPk: jest.fn().mockResolvedValue(null)} + } + }); + const res = await pp.generateHistoryResponse(client, 'u1', 1); + const buttons = res.components[0].components; + expect(buttons[0].disabled).toBe(true); // back + expect(buttons[2].disabled).toBe(true); // next (single page) + }); +}); + +describe('generateActionsResponse', () => { + test('renders no-data and greys the embed when moderation is unconfigured', async () => { + const client = baseClient({ + moderation: [], + models: { + ModerationLog: { + findAndCountAll: jest.fn().mockResolvedValue({ + count: 0, + rows: [] + }) + } + } + }); + const res = await pp.generateActionsResponse(client, 'u1', 1); + expect(res.embeds[0].description).toContain('no-data-found'); + }); + + test('lists mod actions with reason + duration when present', async () => { + const client = baseClient({ + moderation: [{actionType: 'MUTE'}], + models: { + ModerationLog: { + findAndCountAll: jest.fn().mockResolvedValue({ + count: 1, + rows: [{ + type: 'MUTE', + actionDuration: 10, + reason: 'spam', + createdAt: new Date() + }] + }) + } + } + }); + const res = await pp.generateActionsResponse(client, 'u1', 1); + expect(res.embeds[0].description).toContain('MUTE'); + expect(res.embeds[0].description).toContain('spam'); + }); +}); + +describe('generatePanelDeletion', () => { + test('adds a cooldown notice when a deletion cooldown is active', async () => { + const client = baseClient({ + models: { + DeletionCooldown: { + findByPk: jest.fn().mockResolvedValue({ + blockedUntil: new Date(Date.now() + 100000), + lastDeletionType: 'del_all' + }) + } + } + }); + const res = await pp.generatePanelDeletion(client, userObj()); + expect(res.embeds[0].description).toContain('panel-deletion-cooldown-active'); + }); +}); + +describe('sendPingWarning', () => { + function target() { + return { + id: 't1', + username: 'Victim', + toString: () => '<@t1>' + }; + } + + test('does not reply without a configured warning message', async () => { + const client = baseClient(); + const message = { + reply: jest.fn(), + channel: {send: jest.fn()} + }; + const res = await pp.sendPingWarning(client, message, target(), {}); + expect(res).toBeUndefined(); + expect(message.reply).not.toHaveBeenCalled(); + }); + + test('replies with the warning when one is configured', async () => { + const client = baseClient(); + const message = { + author: {id: 'pinger'}, + reply: jest.fn().mockResolvedValue({id: 'reply'}), + channel: { + id: 'c', + send: jest.fn() + } + }; + const res = await pp.sendPingWarning(client, message, target(), {pingWarningMessage: {description: 'stop %target-name%'}}); + expect(message.reply).toHaveBeenCalled(); + expect(res).toEqual({id: 'reply'}); + }); + + test('falls back to channel.send when reply fails', async () => { + const client = baseClient(); + const message = { + author: {id: 'pinger'}, + reply: jest.fn().mockRejectedValue(new Error('no perms')), + channel: { + id: 'c', + send: jest.fn().mockResolvedValue({id: 'chan-msg'}) + } + }; + const res = await pp.sendPingWarning(client, message, target(), {pingWarningMessage: {description: 'x'}}); + expect(message.channel.send).toHaveBeenCalled(); + expect(res).toEqual({id: 'chan-msg'}); + }); +}); + +describe('syncNativeAutoMod', () => { + function guildWith({ + existingRule = null, + ruleOps = {} + } = {}) { + return { + channels: { + fetch: jest.fn().mockResolvedValue(), + cache: {get: jest.fn(() => ({type: 0}))} + }, + members: {cache: {forEach: jest.fn()}}, + autoModerationRules: { + fetch: jest.fn().mockResolvedValue({find: () => existingRule}), + create: jest.fn().mockResolvedValue(), + edit: jest.fn().mockResolvedValue(), + ...ruleOps + } + }; + } + + test('deletes the existing rule when automod is disabled', async () => { + const del = jest.fn().mockResolvedValue(); + const guild = guildWith({ + existingRule: { + id: 'r1', + delete: del + } + }); + const client = baseClient({}); + client.guildID = 'g1'; + client.guilds = {fetch: jest.fn().mockResolvedValue(guild)}; + client.configurations['ping-protection'].configuration = {enableAutomod: false}; + await pp.syncNativeAutoMod(client); + expect(del).toHaveBeenCalled(); + }); + + test('creates a rule with protected keywords when enabled and none exists', async () => { + const guild = guildWith({existingRule: null}); + const client = baseClient({}); + client.guildID = 'g1'; + client.guilds = {fetch: jest.fn().mockResolvedValue(guild)}; + client.configurations['ping-protection'].configuration = { + enableAutomod: true, + protectedRoles: ['role1'], + protectedUsers: ['user1'], + ignoredChannels: [], + ignoredRoles: [] + }; + await pp.syncNativeAutoMod(client); + expect(guild.autoModerationRules.create).toHaveBeenCalled(); + const data = guild.autoModerationRules.create.mock.calls[0][0]; + expect(data.triggerMetadata.keywordFilter).toEqual(expect.arrayContaining(['<@&role1>', '<@user1>', '<@!user1>'])); + }); + + test('edits the existing rule when enabled', async () => { + const guild = guildWith({ + existingRule: { + id: 'r1', + delete: jest.fn() + } + }); + const client = baseClient({}); + client.guildID = 'g1'; + client.guilds = {fetch: jest.fn().mockResolvedValue(guild)}; + client.configurations['ping-protection'].configuration = { + enableAutomod: true, + protectedRoles: ['role1'], + protectedUsers: [], + ignoredChannels: [], + ignoredRoles: [] + }; + await pp.syncNativeAutoMod(client); + expect(guild.autoModerationRules.edit).toHaveBeenCalledWith('r1', expect.any(Object)); + }); +}); \ No newline at end of file diff --git a/tests/polls/botReady.test.js b/tests/polls/botReady.test.js new file mode 100644 index 00000000..2f36a07e --- /dev/null +++ b/tests/polls/botReady.test.js @@ -0,0 +1,49 @@ +/* + * Tests for polls/botReady: on startup it re-schedules an end job for every + * poll whose expiresAt is still in the future, and skips polls that already + * expired or have no expiry. + */ +const mockScheduleJob = jest.fn(); +jest.mock('node-schedule', () => ({scheduleJob: (...a) => mockScheduleJob(...a)})); +jest.mock('../../modules/polls/polls', () => ({updateMessage: jest.fn().mockResolvedValue()})); + +const handler = require('../../modules/polls/events/botReady'); + +beforeEach(() => mockScheduleJob.mockClear()); + +function makeClient(polls) { + return { + models: {polls: {Poll: {findAll: jest.fn().mockResolvedValue(polls)}}}, + channels: {fetch: jest.fn().mockResolvedValue({id: 'c'})} + }; +} + +test('schedules a job only for future, non-expired polls', async () => { + const future = new Date(Date.now() + 100000); + const past = new Date(Date.now() - 100000); + const client = makeClient([ + { + messageID: '1', + channelID: 'c', + expiresAt: future + }, + { + messageID: '2', + channelID: 'c', + expiresAt: past + }, + { + messageID: '3', + channelID: 'c', + expiresAt: null + } + ]); + await handler.run(client); + expect(mockScheduleJob).toHaveBeenCalledTimes(1); +}); + +test('schedules nothing when there are no polls', async () => { + const client = makeClient([]); + await handler.run(client); + expect(mockScheduleJob).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/polls/interactionCreate.test.js b/tests/polls/interactionCreate.test.js new file mode 100644 index 00000000..be2f65af --- /dev/null +++ b/tests/polls/interactionCreate.test.js @@ -0,0 +1,77 @@ +/* + * Regression test: casting/removing a poll vote used to await poll.save() then + * mockUpdateMessage() (a REST message edit) before replying, with no defer. Under load the + * reply landed after Discord's 3s window. Both vote branches must now deferReply first. + */ +jest.mock('../../src/functions/localize', () => ({localize: (file, key) => `${file}.${key}`})); + +const mockUpdateMessage = jest.fn().mockResolvedValue(); +jest.mock('../../modules/polls/polls', () => ({updateMessage: (...args) => mockUpdateMessage(...args)})); + +const handler = require('../../modules/polls/events/interactionCreate'); + +function makePoll() { + return { + votes: {'1': []}, + options: ['A', 'B'], + description: 'desc', + expiresAt: null, + endAt: null, + messageID: 'msg1', + save: jest.fn().mockResolvedValue() + }; +} + +function makeClient(poll) { + return {models: {polls: {Poll: {findOne: jest.fn().mockResolvedValue(poll)}}}}; +} + +function baseInteraction() { + return { + isButton: () => false, + isSelectMenu: () => false, + user: {id: 'u1'}, + message: { + id: 'msg1', + channel: {id: 'c1'} + }, + channel: {id: 'c1'}, + deferReply: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue() + }; +} + +beforeEach(() => mockUpdateMessage.mockClear()); + +test('polls-vote acknowledges before persisting and re-rendering the poll', async () => { + const poll = makePoll(); + const interaction = baseInteraction(poll); + interaction.isSelectMenu = () => true; + interaction.customId = 'polls-vote'; + interaction.values = ['0']; + + await handler.run(makeClient(poll), interaction); + + expect(interaction.deferReply).toHaveBeenCalledTimes(1); + const deferOrder = interaction.deferReply.mock.invocationCallOrder[0]; + expect(poll.save.mock.invocationCallOrder[0]).toBeGreaterThan(deferOrder); + expect(mockUpdateMessage.mock.invocationCallOrder[0]).toBeGreaterThan(deferOrder); + expect(interaction.editReply).toHaveBeenCalled(); + expect(interaction.reply).not.toHaveBeenCalled(); +}); + +test('polls-rem-vot- acknowledges before persisting and re-rendering the poll', async () => { + const poll = makePoll(); + const interaction = baseInteraction(poll); + interaction.isButton = () => true; + interaction.customId = 'polls-rem-vot-msg1'; + + await handler.run(makeClient(poll), interaction); + + expect(interaction.deferReply).toHaveBeenCalledTimes(1); + const deferOrder = interaction.deferReply.mock.invocationCallOrder[0]; + expect(mockUpdateMessage.mock.invocationCallOrder[0]).toBeGreaterThan(deferOrder); + expect(interaction.editReply).toHaveBeenCalled(); + expect(interaction.reply).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/polls/interactionLogic.test.js b/tests/polls/interactionLogic.test.js new file mode 100644 index 00000000..6814d677 --- /dev/null +++ b/tests/polls/interactionLogic.test.js @@ -0,0 +1,270 @@ +/* + * Branch-coverage tests for the polls interactionCreate handler beyond the + * existing defer regression test. Covers: + * - early returns (no poll, no message + non-remove customId) + * - polls-own-vote: not voted / voted (with remove button only when not expired) + * - polls-public-votes: rejects private polls, lists voters for public ones + * - polls-vote multi-select: clears prior votes then records all selected + * - polls-rem-vot-: removes the user from every option bucket + * - expired guard blocks new votes + * + * polls.updateMessage is mocked; we assert directly on the mutated poll.votes + * and on the reply/editReply payloads. + */ +jest.mock('../../modules/polls/polls', () => ({updateMessage: jest.fn().mockResolvedValue()})); + +const handler = require('../../modules/polls/events/interactionCreate'); +const {updateMessage} = require('../../modules/polls/polls'); + +function makePoll(overrides = {}) { + return { + votes: { + '1': [], + '2': [], + '3': [] + }, + options: ['A', 'B', 'C'], + description: 'desc', + expiresAt: null, + endAt: null, + messageID: 'msg1', + channelID: 'c1', + save: jest.fn().mockResolvedValue(), + ...overrides + }; +} + +function makeClient(poll) { + return { + models: {polls: {Poll: {findOne: jest.fn().mockResolvedValue(poll)}}}, + configurations: {polls: {config: {reactions: [null, '1️⃣', '2️⃣', '3️⃣']}}} + }; +} + +function baseInteraction(overrides = {}) { + return { + isButton: () => false, + isSelectMenu: () => false, + user: {id: 'u1'}, + message: { + id: 'msg1', + channel: {id: 'c1'} + }, + channel: {id: 'c1'}, + client: null, + deferReply: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + ...overrides + }; +} + +beforeEach(() => updateMessage.mockClear()); + +describe('early returns', () => { + test('returns when there is no message and customId is not a remove-vote', async () => { + const client = makeClient(makePoll()); + const interaction = baseInteraction({ + message: null, + customId: 'something-else', + isButton: () => true + }); + await handler.run(client, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + expect(client.models.polls.Poll.findOne).not.toHaveBeenCalled(); + }); + + test('returns silently when the poll does not exist', async () => { + const client = makeClient(null); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'polls-own-vote' + }); + await handler.run(client, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + }); +}); + +describe('polls-own-vote', () => { + test('tells a non-voter they have not voted', async () => { + const client = makeClient(makePoll()); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'polls-own-vote' + }); + await handler.run(client, interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('polls.not-voted-yet'); + }); + + test('lists the voted option and offers a remove button when open', async () => { + const poll = makePoll({ + votes: { + '1': ['u1'], + '2': [], + '3': [] + } + }); + const client = makeClient(poll); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'polls-own-vote' + }); + await handler.run(client, interaction); + const payload = interaction.reply.mock.calls[0][0]; + expect(payload.content).toContain('polls.you-voted'); + expect(payload.content).toContain('polls.change-opinion'); + const buttons = payload.components[0].components; + expect(buttons[0].customId).toBe('polls-rem-vot-msg1'); + }); + + test('omits the remove button when the poll already expired', async () => { + const poll = makePoll({ + votes: { + '1': ['u1'], + '2': [], + '3': [] + }, + expiresAt: new Date(Date.now() - 1000) + }); + const client = makeClient(poll); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'polls-own-vote' + }); + await handler.run(client, interaction); + const payload = interaction.reply.mock.calls[0][0]; + expect(payload.content).not.toContain('polls.change-opinion'); + expect(payload.components[0].components).toEqual([]); + }); +}); + +describe('polls-public-votes', () => { + test('rejects when the poll is not public', async () => { + const client = makeClient(makePoll()); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'polls-public-votes', + client + }); + interaction.client = client; + await handler.run(client, interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('polls.not-public'); + }); + + test('lists voters per option for a public poll', async () => { + const poll = makePoll({ + description: '[PUBLIC]desc', + votes: { + '1': ['a', 'b'], + '2': [], + '3': [] + } + }); + const client = makeClient(poll); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'polls-public-votes' + }); + interaction.client = client; + await handler.run(client, interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + const fields = embed.data.fields; + expect(fields[0].value).toContain('<@a>'); + expect(fields[0].value).toContain('<@b>'); + // empty option falls back to "no votes" localized string + expect(fields[1].value).toContain('polls.no-votes-for-this-option'); + }); +}); + +describe('polls-vote (select menu)', () => { + test('records multiple selected options after clearing prior votes', async () => { + const poll = makePoll({ + votes: { + '1': ['u1'], + '2': [], + '3': [] + } + }); + const client = makeClient(poll); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'polls-vote', + values: ['1', '2'] + }); + await handler.run(client, interaction); + // old vote in bucket 1 cleared, new votes for options 1->bucket2 and 2->bucket3 + expect(poll.votes['1']).not.toContain('u1'); + expect(poll.votes['2']).toContain('u1'); + expect(poll.votes['3']).toContain('u1'); + expect(poll.save).toHaveBeenCalled(); + expect(updateMessage).toHaveBeenCalledWith(interaction.message.channel, poll, 'msg1'); + expect(interaction.editReply.mock.calls[0][0].content).toBe('polls.voted-successfully'); + }); + + test('does not double-add when re-voting the same option', async () => { + const poll = makePoll({ + votes: { + '1': [], + '2': ['u1'], + '3': [] + } + }); + const client = makeClient(poll); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'polls-vote', + values: ['1'] + }); + await handler.run(client, interaction); + expect(poll.votes['2'].filter(v => v === 'u1')).toHaveLength(1); + }); + + test('does not record a vote on an expired poll', async () => { + const poll = makePoll({expiresAt: new Date(Date.now() - 1000)}); + const client = makeClient(poll); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'polls-vote', + values: ['0'] + }); + await handler.run(client, interaction); + expect(poll.save).not.toHaveBeenCalled(); + expect(updateMessage).not.toHaveBeenCalled(); + }); +}); + +describe('polls-rem-vot-', () => { + test('removes the user from every bucket and re-renders', async () => { + const poll = makePoll({ + votes: { + '1': ['u1', 'x'], + '2': ['u1'], + '3': [] + } + }); + const client = makeClient(poll); + const interaction = baseInteraction({ + message: null, + isButton: () => true, + customId: 'polls-rem-vot-msg1' + }); + await handler.run(client, interaction); + expect(poll.votes['1']).toEqual(['x']); + expect(poll.votes['2']).toEqual([]); + expect(poll.save).toHaveBeenCalled(); + expect(updateMessage).toHaveBeenCalledWith(interaction.channel, poll, 'msg1'); + expect(interaction.editReply.mock.calls[0][0].content).toContain('polls.removed-vote'); + }); + + test('looks the poll up by the id embedded in the customId', async () => { + const poll = makePoll(); + const client = makeClient(poll); + const interaction = baseInteraction({ + message: null, + isButton: () => true, + customId: 'polls-rem-vot-abc' + }); + await handler.run(client, interaction); + expect(client.models.polls.Poll.findOne).toHaveBeenCalledWith({where: {messageID: 'abc'}}); + }); +}); \ No newline at end of file diff --git a/tests/polls/pollCommand.test.js b/tests/polls/pollCommand.test.js new file mode 100644 index 00000000..61cd9733 --- /dev/null +++ b/tests/polls/pollCommand.test.js @@ -0,0 +1,211 @@ +/* + * Tests for the /poll command (commands/poll.js). + * + * create subcommand: + * - rejects a non-text channel before deferring + * - collects option1..option10, clamps max-selections, prepends [PUBLIC], + * parses duration into endAt, then calls createPoll and confirms + * end subcommand: + * - "not found" reply when no poll matches + * - sets expiresAt, saves, re-renders, and confirms + * autocomplete (end.msg-id): + * - returns only open polls matching the typed value, capped at 25 + * + * createPoll/updateMessage and parseDuration are mocked. + */ +const {ChannelType} = require('discord.js'); + +const mockCreatePoll = jest.fn().mockResolvedValue(); +const mockUpdateMessage = jest.fn().mockResolvedValue(); +jest.mock('../../modules/polls/polls', () => ({ + createPoll: (...a) => mockCreatePoll(...a), + updateMessage: (...a) => mockUpdateMessage(...a) +})); +jest.mock('../../src/functions/parseDuration', () => jest.fn(() => 60000)); + +const command = require('../../modules/polls/commands/poll'); + +function makeOptions(map) { + return { + getChannel: jest.fn((name) => map.channels?.[name]), + getString: jest.fn((name) => (name in (map.strings || {}) ? map.strings[name] : null)), + getBoolean: jest.fn((name) => map.booleans?.[name] ?? null), + getInteger: jest.fn((name) => (name in (map.integers || {}) ? map.integers[name] : null)) + }; +} + +beforeEach(() => { + mockCreatePoll.mockClear(); + mockUpdateMessage.mockClear(); +}); + +describe('create subcommand', () => { + test('rejects a non-text channel before deferring', async () => { + const interaction = { + options: makeOptions({channels: {channel: {type: ChannelType.GuildVoice}}}), + reply: jest.fn().mockResolvedValue(), + deferReply: jest.fn().mockResolvedValue() + }; + await command.subcommands.create(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('polls.not-text-channel'); + expect(interaction.deferReply).not.toHaveBeenCalled(); + expect(mockCreatePoll).not.toHaveBeenCalled(); + }); + + test('builds a public poll, clamps max-selections to option count, and confirms', async () => { + const channel = { + type: ChannelType.GuildText, + toString: () => '#polls' + }; + const interaction = { + client: {}, + options: makeOptions({ + channels: {channel}, + strings: { + description: 'Question?', + option1: 'A', + option2: 'B', + duration: '1m' + }, + booleans: {public: true}, + integers: {'max-selections': 9} // > 2 options -> clamp to 2 + }), + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue() + }; + await command.subcommands.create(interaction); + const data = mockCreatePoll.mock.calls[0][0]; + expect(data.description).toBe('[PUBLIC]Question?'); + expect(data.options).toEqual(['A', 'B']); + expect(data.maxSelections).toBe(2); + expect(data.endAt).toBeInstanceOf(Date); + expect(interaction.editReply.mock.calls[0][0].content).toContain('polls.created-poll'); + }); + + test('defaults max-selections to 1 when omitted and leaves description non-public', async () => { + const channel = { + type: ChannelType.GuildText, + toString: () => '#polls' + }; + const interaction = { + client: {}, + options: makeOptions({ + channels: {channel}, + strings: { + description: 'Q', + option1: 'A', + option2: 'B' + }, + booleans: {public: false}, + integers: {} + }), + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue() + }; + await command.subcommands.create(interaction); + const data = mockCreatePoll.mock.calls[0][0]; + expect(data.description).toBe('Q'); + expect(data.maxSelections).toBe(1); + expect(data.endAt).toBeUndefined(); + }); +}); + +describe('end subcommand', () => { + test('replies not-found when no poll matches the id', async () => { + const interaction = { + client: {models: {polls: {Poll: {findOne: jest.fn().mockResolvedValue(null)}}}}, + options: makeOptions({strings: {'msg-id': 'nope'}}), + reply: jest.fn().mockResolvedValue(), + deferReply: jest.fn().mockResolvedValue() + }; + await command.subcommands.end(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('polls.not-found'); + expect(interaction.deferReply).not.toHaveBeenCalled(); + }); + + test('expires the poll, saves, re-renders and confirms', async () => { + const poll = { + channelID: 'c1', + save: jest.fn().mockResolvedValue() + }; + const channel = {id: 'c1'}; + const interaction = { + client: {models: {polls: {Poll: {findOne: jest.fn().mockResolvedValue(poll)}}}}, + guild: {channels: {cache: {get: jest.fn(() => channel)}}}, + options: makeOptions({strings: {'msg-id': 'm1'}}), + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue() + }; + await command.subcommands.end(interaction); + expect(poll.expiresAt).toBeInstanceOf(Date); + expect(poll.save).toHaveBeenCalled(); + expect(mockUpdateMessage).toHaveBeenCalledWith(channel, poll, 'm1'); + expect(interaction.editReply.mock.calls[0][0].content).toContain('polls.ended-poll'); + }); +}); + +describe('end autocomplete', () => { + const autoComplete = command.autoComplete.end['msg-id']; + + test('lists only open polls matching the typed value', async () => { + const future = new Date(Date.now() + 100000); + const past = new Date(Date.now() - 100000); + const allPolls = [ + { + messageID: 'a1', + description: 'Apple poll', + expiresAt: future, + channelID: 'c' + }, + { + messageID: 'b2', + description: 'Banana poll', + expiresAt: past, + channelID: 'c' + }, + { + messageID: 'c3', + description: 'Apricot', + expiresAt: null, + channelID: 'c' + } + ]; + const respond = jest.fn(); + const interaction = { + value: 'AP', + client: { + models: {polls: {Poll: {findAll: jest.fn().mockResolvedValue(allPolls)}}}, + guild: {channels: {cache: {get: jest.fn(() => ({name: 'general'}))}}} + }, + respond + }; + await autoComplete(interaction); + const result = respond.mock.calls[0][0]; + const ids = result.map(r => r.value); + // a1 (open, "Apple") and c3 (no expiry, "Apricot"); b2 is expired -> excluded + expect(ids).toContain('a1'); + expect(ids).toContain('c3'); + expect(ids).not.toContain('b2'); + }); + + test('caps the suggestions at 25', async () => { + const future = new Date(Date.now() + 100000); + const many = Array.from({length: 40}, (_, i) => ({ + messageID: `m${i}`, + description: `match ${i}`, + expiresAt: future, + channelID: 'c' + })); + const respond = jest.fn(); + const interaction = { + value: 'match', + client: { + models: {polls: {Poll: {findAll: jest.fn().mockResolvedValue(many)}}}, + guild: {channels: {cache: {get: jest.fn(() => ({name: 'g'}))}}} + }, + respond + }; + await autoComplete(interaction); + expect(respond.mock.calls[0][0]).toHaveLength(25); + }); +}); \ No newline at end of file diff --git a/tests/polls/polls.test.js b/tests/polls/polls.test.js new file mode 100644 index 00000000..381ddfc3 --- /dev/null +++ b/tests/polls/polls.test.js @@ -0,0 +1,252 @@ +/* + * Tests for polls.js: createPoll and updateMessage. + * + * createPoll seeds an empty votes map keyed 1..n, persists a Poll row, renders + * the message, and schedules an end job only when endAt is set. + * + * updateMessage builds the embed/components: per-option counts, the live-view + * progress bars, the public/private visibility field, the max-selections field + * (only when effectiveMax > 1), the expired styling, and the extra + * "view public votes" button for public polls. It edits an existing message + * when mID resolves, otherwise sends a new one. + */ +const mockScheduleJob = jest.fn(() => ({cancel: jest.fn()})); +jest.mock('node-schedule', () => ({scheduleJob: (...a) => mockScheduleJob(...a)})); + +const { + createPoll, + updateMessage +} = require('../../modules/polls/polls'); + +function makeChannel({existingMessage = null} = {}) { + const sent = []; + const edited = []; + const channel = { + id: 'chan1', + send: jest.fn(async (p) => { + sent.push(p); + return {id: 'new-msg'}; + }), + messages: { + fetch: jest.fn(async () => existingMessage) + }, + sent, + edited + }; + const message = existingMessage; + if (message) { + message.edit = jest.fn(async (p) => { + edited.push(p); + return {id: message.id}; + }); + } + channel.client = { + configurations: { + polls: { + strings: { + embed: { + title: 'Poll', + color: 'BLUE', + options: 'Options', + liveView: 'Live', + visibility: 'Visibility', + expiresOn: 'Expires', + thisPollExpiresOn: 'on %date%', + endedPollColor: 'RED', + endedPollTitle: 'Ended' + } + }, + config: {reactions: [null, '1️⃣', '2️⃣', '3️⃣']} + } + } + }; + return { + channel, + message + }; +} + +beforeEach(() => mockScheduleJob.mockClear()); + +describe('updateMessage', () => { + test('renders option counts, live view and a private visibility field', async () => { + const {channel} = makeChannel(); + const data = { + description: 'Question?', + options: ['A', 'B'], + votes: { + '1': ['u1'], + '2': [] + } + }; + const id = await updateMessage(channel, data); + expect(id).toBe('new-msg'); + const payload = channel.sent[0]; + const embed = payload.embeds[0]; + const optionsField = embed.data.fields.find(f => f.name === 'Options'); + expect(optionsField.value).toContain('1️⃣: A `1`'); + expect(optionsField.value).toContain('2️⃣: B `0`'); + const visField = embed.data.fields.find(f => f.name === 'Visibility'); + expect(visField.value).toBe('polls.poll-private'); + }); + + test('marks a [PUBLIC] poll public and adds the public-votes button', async () => { + const {channel} = makeChannel(); + const data = { + description: '[PUBLIC]Q', + options: ['A', 'B'], + votes: { + '1': [], + '2': [] + } + }; + await updateMessage(channel, data); + const payload = channel.sent[0]; + const visField = payload.embeds[0].data.fields.find(f => f.name === 'Visibility'); + expect(visField.value).toBe('polls.poll-public'); + const buttonRow = payload.components[1]; + const ids = buttonRow.components.map(c => c.customId); + expect(ids).toContain('polls-public-votes'); + // description rendered without the [PUBLIC] marker + expect(payload.embeds[0].data.description).toBe('Q'); + }); + + test('adds a max-selections field only when effectiveMax > 1', async () => { + const {channel} = makeChannel(); + const data = { + description: 'Q', + options: ['A', 'B', 'C'], + votes: { + '1': [], + '2': [], + '3': [] + }, + maxSelections: 2 + }; + await updateMessage(channel, data); + const fields = channel.sent[0].embeds[0].data.fields.map(f => f.name); + expect(fields).toContain('polls.max-selections-field'); + // select menu max_values reflects the cap + const menu = channel.sent[0].components[0].components[0]; + expect(menu.max_values).toBe(2); + }); + + test('treats maxSelections 0 as unlimited (capped to option count)', async () => { + const {channel} = makeChannel(); + const data = { + description: 'Q', + options: ['A', 'B'], + votes: { + '1': [], + '2': [] + }, + maxSelections: 0 + }; + await updateMessage(channel, data); + const menu = channel.sent[0].components[0].components[0]; + expect(menu.max_values).toBe(2); + const fields = channel.sent[0].embeds[0].data.fields; + const msField = fields.find(f => f.name === 'polls.max-selections-field'); + expect(msField.value).toBe('polls.max-selections-unlimited'); + }); + + test('omits the max-selections field for single-select polls', async () => { + const {channel} = makeChannel(); + const data = { + description: 'Q', + options: ['A', 'B'], + votes: { + '1': [], + '2': [] + }, + maxSelections: 1 + }; + await updateMessage(channel, data); + const fields = channel.sent[0].embeds[0].data.fields.map(f => f.name); + expect(fields).not.toContain('polls.max-selections-field'); + }); + + test('applies ended styling and disables the menu for an expired poll', async () => { + const {channel} = makeChannel(); + const data = { + description: 'Q', + options: ['A', 'B'], + votes: { + '1': [], + '2': [] + }, + expiresAt: new Date(Date.now() - 5000) + }; + await updateMessage(channel, data); + const payload = channel.sent[0]; + expect(payload.embeds[0].data.title).toBe('Ended'); + expect(payload.components[0].components[0].disabled).toBe(true); + }); + + test('edits an existing message when mID resolves', async () => { + const existing = {id: 'm-old'}; + const { + channel, + message + } = makeChannel({existingMessage: existing}); + const data = { + description: 'Q', + options: ['A'], + votes: {'1': []} + }; + const id = await updateMessage(channel, data, 'm-old'); + expect(message.edit).toHaveBeenCalled(); + expect(channel.send).not.toHaveBeenCalled(); + expect(id).toBe('m-old'); + }); +}); + +describe('createPoll', () => { + function makeClient(channel) { + return { + jobs: [], + models: { + polls: { + Poll: { + create: jest.fn().mockResolvedValue({}), + findOne: jest.fn() + } + } + } + }; + } + + test('seeds an empty votes map and persists the poll without a job when no endAt', async () => { + const {channel} = makeChannel(); + const client = makeClient(channel); + await createPoll({ + description: 'Q', + options: ['A', 'B'], + channel + }, client); + const createArg = client.models.polls.Poll.create.mock.calls[0][0]; + expect(createArg.votes).toEqual({ + '1': [], + '2': [] + }); + expect(createArg.maxSelections).toBe(1); + expect(client.jobs).toHaveLength(0); + expect(mockScheduleJob).not.toHaveBeenCalled(); + }); + + test('schedules an end job and stores maxSelections when endAt is set', async () => { + const {channel} = makeChannel(); + const client = makeClient(channel); + const endAt = new Date(Date.now() + 60000); + await createPoll({ + description: 'Q', + options: ['A', 'B', 'C'], + channel, + endAt, + maxSelections: 2 + }, client); + expect(client.models.polls.Poll.create.mock.calls[0][0].maxSelections).toBe(2); + expect(mockScheduleJob).toHaveBeenCalledTimes(1); + expect(client.jobs).toHaveLength(1); + }); +}); \ No newline at end of file diff --git a/tests/quiz/botReady.test.js b/tests/quiz/botReady.test.js new file mode 100644 index 00000000..2e30670a --- /dev/null +++ b/tests/quiz/botReady.test.js @@ -0,0 +1,102 @@ +/* + * Tests for quiz/botReady: re-schedules end jobs for future non-private quizzes, + * optionally forces a leaderboard render + sets up a refresh interval, and always + * registers the daily-reset cron job. + */ +const mockScheduleJob = jest.fn(() => 'job'); +jest.mock('node-schedule', () => ({scheduleJob: (...a) => mockScheduleJob(...a)})); +const mockUpdateLeaderboard = jest.fn().mockResolvedValue(); +const mockUpdateMessage = jest.fn().mockResolvedValue(); +jest.mock('../../modules/quiz/quizUtil', () => ({ + updateLeaderboard: (...a) => mockUpdateLeaderboard(...a), + updateMessage: (...a) => mockUpdateMessage(...a) +})); + +const handler = require('../../modules/quiz/events/botReady'); + +beforeEach(() => { + jest.useFakeTimers(); + mockScheduleJob.mockClear(); + mockUpdateLeaderboard.mockClear(); +}); +afterEach(() => jest.useRealTimers()); + +function makeClient(quizzes, {leaderboardChannel} = {}) { + return { + jobs: [], + intervals: [], + channels: {fetch: jest.fn().mockResolvedValue({id: 'c'})}, + configurations: {quiz: {config: {leaderboardChannel}}}, + models: { + quiz: { + QuizList: {findAll: jest.fn().mockResolvedValue(quizzes)}, + QuizUser: {findAll: jest.fn().mockResolvedValue([])} + } + } + }; +} + +test('schedules end jobs only for future, non-private quizzes and the daily reset', async () => { + const future = new Date(Date.now() + 100000); + const past = new Date(Date.now() - 100000); + const client = makeClient([ + { + messageID: '1', + channelID: 'c', + private: false, + expiresAt: future + }, + { + messageID: '2', + channelID: 'c', + private: true, + expiresAt: future + }, + { + messageID: '3', + channelID: 'c', + private: false, + expiresAt: past + } + ]); + await handler.run(client); + // 1 future-public end job + 1 daily-reset cron job = 2 schedule calls + expect(mockScheduleJob).toHaveBeenCalledTimes(2); + expect(client.jobs).toHaveLength(1); // only the daily reset is pushed to jobs +}); + +test('forces an initial leaderboard render and registers a refresh interval when configured', async () => { + const client = makeClient([], {leaderboardChannel: 'lb'}); + await handler.run(client); + expect(mockUpdateLeaderboard).toHaveBeenCalledWith(client, true); + expect(client.intervals).toHaveLength(1); +}); + +test('skips the leaderboard refresh interval when no channel is configured', async () => { + const client = makeClient([]); + await handler.run(client); + expect(mockUpdateLeaderboard).not.toHaveBeenCalled(); + expect(client.intervals).toHaveLength(0); +}); + +test('the daily reset job clears each QuizUser dailyQuiz counter', async () => { + let cronCb; + mockScheduleJob.mockImplementation((spec, cb) => { + if (spec === '1 0 * * *') cronCb = cb; + return 'job'; + }); + const users = [{ + dailyQuiz: 5, + save: jest.fn() + }, { + dailyQuiz: 2, + save: jest.fn() + }]; + const client = makeClient([]); + client.models.quiz.QuizUser.findAll.mockResolvedValue(users); + await handler.run(client); + await cronCb(); + expect(users[0].dailyQuiz).toBe(0); + expect(users[0].save).toHaveBeenCalled(); + expect(users[1].dailyQuiz).toBe(0); +}); \ No newline at end of file diff --git a/tests/quiz/interactionCreate.test.js b/tests/quiz/interactionCreate.test.js new file mode 100644 index 00000000..20287ef7 --- /dev/null +++ b/tests/quiz/interactionCreate.test.js @@ -0,0 +1,286 @@ +/* + * Behavioural tests for the quiz interactionCreate handler. + * + * Covers the branch logic of voting/answer handling: + * - show-quiz-rank with and without an existing QuizUser row. + * - quiz-own-vote: reporting the user's prior choice + correctness once expired. + * - quiz-vote on a public quiz: vote recorded, persisted, message re-rendered. + * - "cannot change vote" guard when canChangeVote is false and user already voted. + * - private quiz: correct answer awards XP, wrong answer does not. + * + * quizUtil.updateMessage is mocked so we only exercise the handler's branching. + */ +const mockUpdateMessage = jest.fn().mockResolvedValue('msg-id'); +const mockSetChanged = jest.fn(); +jest.mock('../../modules/quiz/quizUtil', () => ({ + updateMessage: (...a) => mockUpdateMessage(...a), + setChanged: (...a) => mockSetChanged(...a) +})); + +const handler = require('../../modules/quiz/events/interactionCreate'); + +function makeClient({ + quiz = null, + quizUser = null, + quizUsers = [] + } = {}) { + return { + models: { + quiz: { + QuizList: {findOne: jest.fn().mockResolvedValue(quiz)}, + QuizUser: { + findOne: jest.fn().mockResolvedValue(quizUser), + findAll: jest.fn().mockResolvedValue(quizUsers), + update: jest.fn().mockResolvedValue(), + create: jest.fn().mockResolvedValue() + } + } + } + }; +} + +function baseInteraction(overrides = {}) { + return { + message: {id: 'm1'}, + channel: {id: 'c1'}, + user: {id: 'u1'}, + isButton: () => false, + isSelectMenu: () => false, + reply: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue(), + save: jest.fn(), + ...overrides + }; +} + +beforeEach(() => { + mockUpdateMessage.mockClear(); + mockSetChanged.mockClear(); +}); + +describe('show-quiz-rank', () => { + test('replies with the user XP when a rank exists', async () => { + const client = makeClient({quizUser: {xp: 42}}); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'show-quiz-rank' + }); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'quiz.your-rank(xp=42)', + ephemeral: true + }) + ); + }); + + test('replies with no-rank when the user has no record', async () => { + const client = makeClient({quizUser: null}); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'show-quiz-rank' + }); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('quiz.no-rank'), + ephemeral: true + }) + ); + }); +}); + +describe('quiz-own-vote', () => { + test('tells a non-voter they have not voted yet', async () => { + const quiz = { + votes: { + '1': [], + '2': [] + }, + options: [{}, {}] + }; + const client = makeClient({quiz}); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'quiz-own-vote' + }); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: expect.stringContaining('quiz.not-voted-yet')}) + ); + }); + + test('reports the chosen option and correctness once the quiz is expired', async () => { + const quiz = { + votes: { + '1': ['u1'], + '2': [] + }, + options: [{ + text: 'Right', + correct: true + }, { + text: 'Wrong', + correct: false + }], + expiresAt: new Date(Date.now() - 1000) + }; + const client = makeClient({quiz}); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'quiz-own-vote' + }); + await handler.run(client, interaction); + const arg = interaction.reply.mock.calls[0][0].content; + expect(arg).toContain('quiz.you-voted(o=Right)'); + expect(arg).toContain('quiz.answer-correct'); + }); +}); + +describe('public quiz voting (quiz-vote select menu)', () => { + test('records the vote, persists, and re-renders the message', async () => { + const quiz = { + votes: { + '1': [], + '2': [] + }, + options: [{text: 'A'}, {text: 'B'}], + canChangeVote: true, + private: false, + save: jest.fn().mockResolvedValue() + }; + const client = makeClient({quiz}); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'quiz-vote', + values: ['0'] + }); + await handler.run(client, interaction); + // index 0 -> votes bucket "1" + expect(quiz.votes['1']).toContain('u1'); + expect(quiz.save).toHaveBeenCalled(); + expect(mockUpdateMessage).toHaveBeenCalledWith(interaction.channel, quiz, 'm1'); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'quiz.voted-successfully', + ephemeral: true + }) + ); + }); + + test('blocks re-voting when canChangeVote is false', async () => { + const quiz = { + votes: { + '1': ['u1'], + '2': [] + }, + options: [{text: 'A'}, {text: 'B'}], + canChangeVote: false, + private: false, + save: jest.fn().mockResolvedValue() + }; + const client = makeClient({quiz}); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'quiz-vote', + values: ['1'] + }); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'quiz.cannot-change-opinion', + ephemeral: true + }) + ); + expect(mockUpdateMessage).not.toHaveBeenCalled(); + expect(quiz.save).not.toHaveBeenCalled(); + }); +}); + +describe('private quiz voting', () => { + test('awards XP and marks changed for a correct answer', async () => { + const quiz = { + votes: { + '1': [], + '2': [] + }, + options: [{ + text: 'A', + correct: true + }, { + text: 'B', + correct: false + }], + private: true + }; + const client = makeClient({ + quiz, + quizUsers: [{ + dailyXp: 0, + xp: 5 + }] + }); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'quiz-vote', + values: ['0'], + client: makeClient({ + quiz, + quizUsers: [{ + dailyXp: 0, + xp: 5 + }] + }) + }); + await handler.run(client, interaction); + expect(interaction.client.models.quiz.QuizUser.update).toHaveBeenCalledWith( + { + dailyXp: 1, + xp: 6 + }, + {where: {userID: 'u1'}} + ); + expect(mockSetChanged).toHaveBeenCalled(); + expect(interaction.update).toHaveBeenCalled(); + }); + + test('does not award XP for a wrong answer', async () => { + const quiz = { + votes: { + '1': [], + '2': [] + }, + options: [{ + text: 'A', + correct: true + }, { + text: 'B', + correct: false + }], + private: true + }; + const client = makeClient({ + quiz, + quizUsers: [{ + dailyXp: 0, + xp: 5 + }] + }); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'quiz-vote', + values: ['1'], + client: makeClient({ + quiz, + quizUsers: [{ + dailyXp: 0, + xp: 5 + }] + }) + }); + await handler.run(client, interaction); + expect(interaction.client.models.quiz.QuizUser.update).not.toHaveBeenCalled(); + expect(mockSetChanged).not.toHaveBeenCalled(); + expect(interaction.update).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/quiz/interactionEdge.test.js b/tests/quiz/interactionEdge.test.js new file mode 100644 index 00000000..a8e216bd --- /dev/null +++ b/tests/quiz/interactionEdge.test.js @@ -0,0 +1,201 @@ +/* + * Edge-case coverage for the quiz interactionCreate handler not exercised by + * interactionCreate.test.js: + * - early return when the interaction has no message + * - unknown quiz (findOne -> null) returns silently + * - bool-style button vote (quiz-vote-N) on a private quiz + * - private quiz vote ignored when the user has no QuizUser record + * - quiz-own-vote on an open quiz surfaces the change/cannot-change hint + * - select-menu vote on an expired public quiz is ignored + */ +const mockUpdateMessage = jest.fn().mockResolvedValue('msg-id'); +const mockSetChanged = jest.fn(); +jest.mock('../../modules/quiz/quizUtil', () => ({ + updateMessage: (...a) => mockUpdateMessage(...a), + setChanged: (...a) => mockSetChanged(...a) +})); + +const handler = require('../../modules/quiz/events/interactionCreate'); + +function makeClient(quiz, {quizUsers = []} = {}) { + return { + models: { + quiz: { + QuizList: {findOne: jest.fn().mockResolvedValue(quiz)}, + QuizUser: { + findOne: jest.fn().mockResolvedValue(null), + findAll: jest.fn().mockResolvedValue(quizUsers), + update: jest.fn().mockResolvedValue(), + create: jest.fn().mockResolvedValue() + } + } + } + }; +} + +function baseInteraction(overrides = {}) { + return { + message: {id: 'm1'}, + channel: {id: 'c1'}, + user: {id: 'u1'}, + isButton: () => false, + isSelectMenu: () => false, + reply: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue(), + ...overrides + }; +} + +beforeEach(() => { + mockUpdateMessage.mockClear(); + mockSetChanged.mockClear(); +}); + +test('returns immediately when the interaction has no message', async () => { + const client = makeClient(null); + const interaction = baseInteraction({ + message: null, + isButton: () => true, + customId: 'show-quiz-rank' + }); + await handler.run(client, interaction); + expect(client.models.quiz.QuizUser.findOne).not.toHaveBeenCalled(); + expect(interaction.reply).not.toHaveBeenCalled(); +}); + +test('returns silently for an unknown quiz message', async () => { + const client = makeClient(null); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'quiz-vote', + values: ['0'] + }); + await handler.run(client, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + expect(interaction.update).not.toHaveBeenCalled(); +}); + +test('bool button vote on a private quiz awards XP for the correct answer', async () => { + const quiz = { + votes: { + '1': [], + '2': [] + }, + options: [{ + text: 'Yes', + correct: true + }, { + text: 'No', + correct: false + }], + private: true + }; + const client = makeClient(quiz, { + quizUsers: [{ + dailyXp: 0, + xp: 1 + }] + }); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'quiz-vote-0' + }); + interaction.client = makeClient(quiz, { + quizUsers: [{ + dailyXp: 0, + xp: 1 + }] + }); + await handler.run(client, interaction); + expect(interaction.client.models.quiz.QuizUser.update).toHaveBeenCalledWith( + { + dailyXp: 1, + xp: 2 + }, + {where: {userID: 'u1'}} + ); + expect(mockSetChanged).toHaveBeenCalled(); + expect(interaction.update).toHaveBeenCalled(); +}); + +test('private quiz vote is ignored when the user has no record', async () => { + const quiz = { + votes: { + '1': [], + '2': [] + }, + options: [{ + text: 'A', + correct: true + }, {text: 'B'}], + private: true + }; + const client = makeClient(quiz); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'quiz-vote', + values: ['0'] + }); + interaction.client = makeClient(quiz, {quizUsers: []}); // findAll -> [] + await handler.run(client, interaction); + expect(interaction.update).not.toHaveBeenCalled(); + expect(mockSetChanged).not.toHaveBeenCalled(); +}); + +test('quiz-own-vote on an open quiz shows the cannot-change hint when locked', async () => { + const quiz = { + votes: { + '1': ['u1'], + '2': [] + }, + options: [{text: 'A'}, {text: 'B'}], + canChangeVote: false + }; + const client = makeClient(quiz); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'quiz-own-vote' + }); + await handler.run(client, interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('quiz.cannot-change-opinion'); +}); + +test('quiz-own-vote on an open changeable quiz shows the change hint', async () => { + const quiz = { + votes: { + '1': ['u1'], + '2': [] + }, + options: [{text: 'A'}, {text: 'B'}], + canChangeVote: true + }; + const client = makeClient(quiz); + const interaction = baseInteraction({ + isButton: () => true, + customId: 'quiz-own-vote' + }); + await handler.run(client, interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('quiz.change-opinion'); +}); + +test('select-menu vote on an expired public quiz is ignored', async () => { + const quiz = { + votes: { + '1': [], + '2': [] + }, + options: [{text: 'A'}, {text: 'B'}], + private: false, + expiresAt: new Date(Date.now() - 1000), + save: jest.fn() + }; + const client = makeClient(quiz); + const interaction = baseInteraction({ + isSelectMenu: () => true, + customId: 'quiz-vote', + values: ['0'] + }); + await handler.run(client, interaction); + expect(quiz.save).not.toHaveBeenCalled(); + expect(mockUpdateMessage).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/tests/quiz/quizCommand.test.js b/tests/quiz/quizCommand.test.js new file mode 100644 index 00000000..efe5473c --- /dev/null +++ b/tests/quiz/quizCommand.test.js @@ -0,0 +1,263 @@ +/* + * Tests for the /quiz command (commands/quiz.js). + * + * create / create-bool: permission gate on createAllowedRole. + * play: + * - creates a QuizUser row on first play + * - enforces the daily limit + * - "no quiz" when the quiz list is empty + * - continuous mode advances nextQuizID; random mode picks any + * - builds the private quiz (shuffled options, private flag) and bumps the counter + * leaderboard: renders ranked users, skips members not in cache, falls back to + * the empty-leaderboard string. + * + * createQuiz, durationParser, shuffleArray are mocked for determinism. + */ +const mockCreateQuiz = jest.fn().mockResolvedValue(); +jest.mock('../../modules/quiz/quizUtil', () => ({createQuiz: (...a) => mockCreateQuiz(...a)})); +jest.mock('../../src/functions/parseDuration', () => jest.fn(() => 60000)); +jest.mock('../../src/functions/helpers', () => { + const actual = jest.requireActual('../../src/functions/helpers'); + return { + ...actual, + shuffleArray: (a) => a + }; +}); + +const command = require('../../modules/quiz/commands/quiz'); + +beforeEach(() => mockCreateQuiz.mockClear()); + +describe('create permission gating', () => { + test('rejects a member without the create role', async () => { + const interaction = { + client: { + configurations: { + quiz: { + config: { + createAllowedRole: 'role-mod', + emojis: {} + } + } + } + }, + member: {roles: {cache: {has: jest.fn(() => false)}}}, + options: {getSubcommand: () => 'create'}, + reply: jest.fn().mockResolvedValue() + }; + await command.subcommands.create(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('quiz.no-permission'); + }); +}); + +describe('play subcommand', () => { + function playClient({ + user, + quizList, + config = {} + }) { + return { + configurations: { + quiz: { + config: { + dailyQuizLimit: 3, + mode: 'random', ...config + }, + quizList + } + }, + models: { + quiz: { + QuizUser: { + findAll: jest.fn().mockResolvedValue(user ? [user] : []), + create: jest.fn().mockResolvedValue({ + dailyQuiz: 0, + nextQuizID: 0 + }), + update: jest.fn().mockResolvedValue() + } + } + } + }; + } + + test('creates a QuizUser on first play and enforces an empty quiz list', async () => { + const client = playClient({ + user: null, + quizList: [] + }); + const interaction = { + client, + user: {id: 'u1'}, + channel: {id: 'c'}, + reply: jest.fn().mockResolvedValue() + }; + await command.subcommands.play(interaction); + expect(client.models.quiz.QuizUser.create).toHaveBeenCalledWith({ + userID: 'u1', + dailyQuiz: 0 + }); + expect(interaction.reply.mock.calls[0][0].content).toContain('quiz.no-quiz'); + expect(mockCreateQuiz).not.toHaveBeenCalled(); + }); + + test('blocks when the daily quiz limit is reached', async () => { + const client = playClient({ + user: {dailyQuiz: 3}, + quizList: [{}], + config: {dailyQuizLimit: 3} + }); + const interaction = { + client, + user: {id: 'u1'}, + channel: {id: 'c'}, + reply: jest.fn().mockResolvedValue() + }; + await command.subcommands.play(interaction); + expect(interaction.reply.mock.calls[0][0].content).toContain('quiz.daily-quiz-limit'); + expect(mockCreateQuiz).not.toHaveBeenCalled(); + }); + + test('starts a random quiz and bumps the daily counter', async () => { + const quiz = { + wrongOptions: ['W1', 'W2'], + correctOptions: ['C1'], + duration: '1m' + }; + const client = playClient({ + user: { + dailyQuiz: 0, + nextQuizID: 0 + }, + quizList: [quiz], + config: {mode: 'random'} + }); + const interaction = { + client, + user: {id: 'u1'}, + channel: {id: 'c'}, + reply: jest.fn().mockResolvedValue() + }; + await command.subcommands.play(interaction); + expect(mockCreateQuiz).toHaveBeenCalledTimes(1); + const data = mockCreateQuiz.mock.calls[0][0]; + expect(data.private).toBe(true); + expect(data.canChangeVote).toBe(false); + // 2 wrong + 1 correct = 3 options + expect(data.options).toHaveLength(3); + expect(data.options.find(o => o.correct)).toEqual({ + text: 'C1', + correct: true + }); + expect(client.models.quiz.QuizUser.update).toHaveBeenCalledWith( + expect.objectContaining({dailyQuiz: 1}), + {where: {userID: 'u1'}} + ); + }); + + test('continuous mode advances nextQuizID and wraps at the end', async () => { + const quiz0 = { + wrongOptions: [], + correctOptions: ['C'], + duration: '1m' + }; + const quiz1 = { + wrongOptions: [], + correctOptions: ['C'], + duration: '1m' + }; + const client = playClient({ + user: { + dailyQuiz: 0, + nextQuizID: 1 + }, + quizList: [quiz0, quiz1], + config: {mode: 'continuous'} + }); + const interaction = { + client, + user: {id: 'u1'}, + channel: {id: 'c'}, + reply: jest.fn().mockResolvedValue() + }; + await command.subcommands.play(interaction); + // nextQuizID was 1 (last index) -> wraps to 0 + const updateArg = client.models.quiz.QuizUser.update.mock.calls[0][0]; + expect(updateArg.nextQuizID).toBe(0); + }); +}); + +describe('leaderboard subcommand', () => { + function lbClient(users, membersByID) { + return { + strings: {disableFooterTimestamp: true}, + configurations: { + quiz: { + strings: { + embed: { + leaderboardTitle: 'LB', + leaderboardColor: 'BLUE', + leaderboardSubtitle: 'Top', + leaderboardButton: 'Mine' + } + } + } + }, + models: {quiz: {QuizUser: {findAll: jest.fn().mockResolvedValue(users)}}} + }; + } + + test('ranks cached members and skips uncached ones', async () => { + const users = [{ + userID: 'a', + xp: 10 + }, { + userID: 'ghost', + xp: 5 + }, { + userID: 'b', + xp: 3 + }]; + const membersByID = { + a: {user: {toString: () => '<@a>'}}, + b: {user: {toString: () => '<@b>'}} + }; + const client = lbClient(users); + const interaction = { + client, + guild: { + members: {cache: {get: jest.fn((id) => membersByID[id])}}, + iconURL: () => 'http://icon' + }, + reply: jest.fn().mockResolvedValue() + }; + await command.subcommands.leaderboard(interaction); + const embed = interaction.reply.mock.calls[0][0].embeds[0]; + const field = embed.data.fields[0]; + expect(field.value).toContain('quiz.leaderboard-notation'); + // ghost (not cached) excluded -> only 2 ranked lines + expect(field.value.split('\n').filter(Boolean)).toHaveLength(2); + }); + + test('falls back to the empty-leaderboard string when nobody qualifies', async () => { + const client = lbClient([{ + userID: 'ghost', + xp: 1 + }]); + const interaction = { + client, + guild: { + members: {cache: {get: jest.fn(() => undefined)}}, + iconURL: () => 'http://icon' + }, + reply: jest.fn().mockResolvedValue() + }; + await command.subcommands.leaderboard(interaction); + const field = interaction.reply.mock.calls[0][0].embeds[0].data.fields[0]; + expect(field.value).toContain('levels.no-user-on-leaderboard'); + }); +}); + +test('create and create-bool share the same handler', () => { + expect(command.subcommands['create-bool']).toBe(command.subcommands.create); +}); \ No newline at end of file diff --git a/tests/quiz/quizUtil.test.js b/tests/quiz/quizUtil.test.js new file mode 100644 index 00000000..028cfcf6 --- /dev/null +++ b/tests/quiz/quizUtil.test.js @@ -0,0 +1,367 @@ +/* + * Tests for quizUtil.js: createQuiz, updateMessage, updateLeaderboard, setChanged. + * + * createQuiz seeds an empty votes map, renders the message, persists a QuizList + * row, and (for non-private timed quizzes) schedules an end job. + * + * updateMessage builds the embed/components for: + * - normal (select menu) vs bool (two buttons) quizzes + * - an "own vote" button on public quizzes, absent on private ones + * - expired quizzes: disabled components + correctness highlighting, and on a + * correct answer it grants XP (update existing / create new QuizUser) + * + * updateLeaderboard short-circuits without a configured channel and on no change, + * and renders/edits the leaderboard embed when forced. + */ +const mockScheduleJob = jest.fn(() => ({cancel: jest.fn()})); +jest.mock('node-schedule', () => ({scheduleJob: (...a) => mockScheduleJob(...a)})); + +const quizUtil = require('../../modules/quiz/quizUtil'); +const {ChannelType} = require('discord.js'); + +function makeChannel({ + existing = null, + configOverrides = {} + } = {}) { + const sent = []; + const channel = { + id: 'chan1', + send: jest.fn(async (p) => { + sent.push(p); + return {id: 'new-msg'}; + }), + messages: {fetch: jest.fn(async () => existing)}, + sent + }; + channel.client = { + configurations: { + quiz: { + strings: { + embed: { + title: 'Quiz', + color: 'BLUE', + options: 'Options', + liveView: 'Live', + expiresOn: 'Expires', + thisQuizExpiresOn: 'on %date%', + endedQuizColor: 'RED', + endedQuizTitle: 'Ended' + } + }, + config: { + emojis: ['0️⃣', '1️⃣', '2️⃣'], + livePreview: true, ...configOverrides + } + } + }, + jobs: [], + models: { + quiz: { + QuizUser: { + findAll: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(), + create: jest.fn().mockResolvedValue() + }, + QuizList: { + create: jest.fn().mockResolvedValue({}), + findOne: jest.fn().mockResolvedValue({}) + } + } + } + }; + return channel; +} + +beforeEach(() => mockScheduleJob.mockClear()); + +describe('updateMessage', () => { + test('renders a normal quiz with a select menu and own-vote button (public)', async () => { + const channel = makeChannel(); + const data = { + description: 'Q?', + options: [{text: 'A'}, {text: 'B'}], + votes: { + '1': ['u1'], + '2': [] + }, + type: 'normal', + private: false + }; + const id = await quizUtil.updateMessage(channel, data); + expect(id).toBe('new-msg'); + const payload = channel.sent[0]; + const menu = payload.components[0].components[0]; + expect(menu.type).toBe('SELECT_MENU'); + const customIds = payload.components.flatMap(r => r.components.map(c => c.customId)); + expect(customIds).toContain('quiz-own-vote'); + }); + + test('renders a bool quiz with two buttons and no own-vote button when private', async () => { + const channel = makeChannel(); + const data = { + description: 'True?', + options: [{text: 'Yes'}, {text: 'No'}], + votes: { + '1': [], + '2': [] + }, + type: 'bool', + private: true + }; + await quizUtil.updateMessage(channel, data); + const payload = channel.sent[0]; + const firstRow = payload.components[0].components; + expect(firstRow.map(c => c.customId)).toEqual(['quiz-vote-0', 'quiz-vote-1']); + const allIds = payload.components.flatMap(r => r.components.map(c => c.customId)); + expect(allIds).not.toContain('quiz-own-vote'); + }); + + test('expired quiz disables components and awards XP to a correct voter (existing user)', async () => { + const channel = makeChannel(); + channel.client.models.quiz.QuizUser.findAll.mockResolvedValue([{ + dailyXp: 2, + xp: 5 + }]); + const data = { + description: 'Q', + options: [{ + text: 'Right', + correct: true + }, {text: 'Wrong'}], + votes: { + '1': ['voter1'], + '2': [] + }, + type: 'normal', + private: false, + expiresAt: new Date(Date.now() - 1000) + }; + await quizUtil.updateMessage(channel, data); + // wait a tick for the async forEach voter handling + await new Promise(r => setImmediate(r)); + expect(channel.client.models.quiz.QuizUser.update).toHaveBeenCalledWith( + { + dailyXp: 3, + xp: 6 + }, + {where: {userID: 'voter1'}} + ); + const menu = channel.sent[0].components[0].components[0]; + expect(menu.disabled).toBe(true); + }); + + test('expired quiz creates a new QuizUser for a correct voter with no record', async () => { + const channel = makeChannel(); + channel.client.models.quiz.QuizUser.findAll.mockResolvedValue([]); + const data = { + description: 'Q', + options: [{ + text: 'Right', + correct: true + }, {text: 'Wrong'}], + votes: { + '1': ['fresh'], + '2': [] + }, + type: 'normal', + private: false, + expiresAt: new Date(Date.now() - 1000) + }; + await quizUtil.updateMessage(channel, data); + await new Promise(r => setImmediate(r)); + expect(channel.client.models.quiz.QuizUser.create).toHaveBeenCalledWith({ + userID: 'fresh', + dailyXp: 1, + xp: 1 + }); + }); + + test('edits an existing message instead of sending a new one', async () => { + const existing = { + id: 'm-old', + edit: jest.fn(async () => ({id: 'm-old'})) + }; + const channel = makeChannel({existing}); + const data = { + description: 'Q', + options: [{text: 'A'}], + votes: {'1': []}, + type: 'normal', + private: false + }; + const id = await quizUtil.updateMessage(channel, data, 'm-old'); + expect(existing.edit).toHaveBeenCalled(); + expect(channel.send).not.toHaveBeenCalled(); + expect(id).toBe('m-old'); + }); + + test('private timed quiz replies ephemerally via the interaction', async () => { + const channel = makeChannel(); + const interaction = {reply: jest.fn(async () => ({id: 'int-msg'}))}; + const data = { + description: 'Q', + options: [{text: 'A'}, {text: 'B'}], + votes: { + '1': [], + '2': [] + }, + type: 'normal', + private: true + }; + const id = await quizUtil.updateMessage(channel, data, null, interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ + ephemeral: true, + fetchReply: true + })); + expect(id).toBe('int-msg'); + }); +}); + +describe('createQuiz', () => { + test('seeds votes, renders, persists and schedules an end job for a public timed quiz', async () => { + const channel = makeChannel(); + const client = channel.client; + const data = { + description: 'Q', + options: [{text: 'A'}, {text: 'B'}], + channel, + endAt: new Date(Date.now() + 60000), + type: 'normal', + private: false, + canChangeVote: true + }; + await quizUtil.createQuiz(data, client); + const createArg = client.models.quiz.QuizList.create.mock.calls[0][0]; + expect(createArg.votes).toEqual({ + '1': [], + '2': [] + }); + expect(createArg.private).toBe(false); + expect(mockScheduleJob).toHaveBeenCalledTimes(1); + }); + + test('does not schedule a job for a private quiz', async () => { + const channel = makeChannel(); + const client = channel.client; + client.jobs = []; + const interaction = {reply: jest.fn(async () => ({id: 'm'}))}; + const data = { + description: 'Q', + options: [{text: 'A'}, {text: 'B'}], + channel, + endAt: new Date(Date.now() + 60000), + type: 'normal', + private: true, + canChangeVote: false + }; + await quizUtil.createQuiz(data, client, interaction); + expect(mockScheduleJob).not.toHaveBeenCalled(); + }); +}); + +describe('updateLeaderboard', () => { + test('returns early when no leaderboard channel is configured', async () => { + const client = { + configurations: {quiz: {config: {}}}, + channels: {fetch: jest.fn()} + }; + await quizUtil.updateLeaderboard(client, true); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + test('returns early when nothing changed and not forced (fresh module state)', async () => { + // changed is module-global; load a pristine copy so it starts false + let freshUtil; + jest.isolateModules(() => { + freshUtil = require('../../modules/quiz/quizUtil'); + }); + const client = { + configurations: {quiz: {config: {leaderboardChannel: 'lb'}}}, + channels: {fetch: jest.fn()} + }; + await freshUtil.updateLeaderboard(client, false); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + test('setChanged flips the change flag so a non-forced update proceeds', async () => { + let freshUtil; + jest.isolateModules(() => { + freshUtil = require('../../modules/quiz/quizUtil'); + }); + freshUtil.setChanged(); + const client = { + configurations: { + quiz: { + config: {leaderboardChannel: 'lb'}, + strings: {embed: {}} + } + }, + channels: {fetch: jest.fn().mockResolvedValue(null)}, + logger: {error: jest.fn()} + }; + await freshUtil.updateLeaderboard(client, false); + // proceeded past the change guard -> attempted to fetch the channel + expect(client.channels.fetch).toHaveBeenCalled(); + }); + + test('renders and sends the leaderboard embed when forced', async () => { + const messages = {filter: () => ({first: () => null})}; + const channel = { + type: ChannelType.GuildText, + guild: { + members: {cache: {get: () => ({user: {toString: () => '<@a>'}})}}, + iconURL: () => 'http://i' + }, + messages: {fetch: jest.fn().mockResolvedValue(messages)}, + send: jest.fn().mockResolvedValue({}) + }; + const client = { + user: {id: 'bot'}, + strings: {disableFooterTimestamp: true}, + configurations: { + quiz: { + config: {leaderboardChannel: 'lb'}, + strings: { + embed: { + leaderboardTitle: 'LB', + leaderboardColor: 'BLUE', + leaderboardSubtitle: 'Top', + leaderboardButton: 'Mine' + } + } + } + }, + channels: {fetch: jest.fn().mockResolvedValue(channel)}, + logger: {error: jest.fn()}, + models: { + quiz: { + QuizUser: { + findAll: jest.fn().mockResolvedValue([{ + userID: 'a', + xp: 9 + }]) + } + } + } + }; + await quizUtil.updateLeaderboard(client, true); + expect(channel.send).toHaveBeenCalled(); + const embed = channel.send.mock.calls[0][0].embeds[0]; + expect(embed.data.fields[0].value).toContain('quiz.leaderboard-notation'); + }); + + test('logs an error when the configured channel is missing or not text', async () => { + const client = { + configurations: { + quiz: { + config: {leaderboardChannel: 'lb'}, + strings: {embed: {}} + } + }, + channels: {fetch: jest.fn().mockResolvedValue(null)}, + logger: {error: jest.fn()} + }; + await quizUtil.updateLeaderboard(client, true); + expect(client.logger.error).toHaveBeenCalledWith(expect.stringContaining('quiz.leaderboard-channel-not-found')); + }); +}); \ No newline at end of file diff --git a/tests/reaction-roles/reactionHandlers.test.js b/tests/reaction-roles/reactionHandlers.test.js new file mode 100644 index 00000000..5d5b0d44 --- /dev/null +++ b/tests/reaction-roles/reactionHandlers.test.js @@ -0,0 +1,186 @@ +/* + * Tests for the reaction-roles add/remove event handlers. + * + * Both handlers share the same guard chain and config lookup: + * - ignore reactions before the bot is ready + * - fetch partial reactions + * - ignore reactions from other guilds + * - (add only) ignore the bot's own reaction + * - find the configured message, then the role mapping for the emoji + * - add/remove the comma-separated role list to the reacting member + * The add handler additionally re-reacts so the emoji stays clickable. + */ + +const addHandler = require('../../modules/reaction-roles/events/messageReactionAdd'); +const removeHandler = require('../../modules/reaction-roles/events/messageReactionRemove'); + +function makeMember() { + return { + roles: { + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + } + }; +} + +function makeClient(messages) { + return { + botReadyAt: Date.now(), + guild: {id: 'g1'}, + user: {id: 'bot1'}, + configurations: {'reaction-roles': {messages}} + }; +} + +function makeReaction({ + emoji = '👍', + messageID = 'msg1', + member = makeMember() + } = {}) { + return { + partial: false, + _emoji: {toString: () => emoji}, + message: { + id: messageID, + guildId: 'g1', + react: jest.fn().mockResolvedValue(), + guild: { + members: {fetch: jest.fn().mockResolvedValue(member)} + } + } + }; +} + +const config = [{ + messageID: 'msg1', + reactions: { + '👍': 'role-a,role-b', + '🔥': 'role-c' + } +}]; + +describe('reaction-roles add handler', () => { + test('adds the comma-split roles for a matching emoji', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + emoji: '👍', + member + }); + await addHandler.run(client, reaction, {id: 'user1'}); + expect(member.roles.add).toHaveBeenCalledWith(['role-a', 'role-b']); + // re-reacts to keep the emoji available + expect(reaction.message.react).toHaveBeenCalledWith('👍'); + }); + + test('ignores reactions before the bot is ready', async () => { + const client = makeClient(config); + client.botReadyAt = undefined; + const member = makeMember(); + const reaction = makeReaction({member}); + await addHandler.run(client, reaction, {id: 'user1'}); + expect(reaction.message.guild.members.fetch).not.toHaveBeenCalled(); + expect(member.roles.add).not.toHaveBeenCalled(); + }); + + test('ignores the bot\'s own reaction', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({member}); + await addHandler.run(client, reaction, {id: 'bot1'}); + expect(member.roles.add).not.toHaveBeenCalled(); + }); + + test('ignores reactions from a different guild', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({member}); + reaction.message.guildId = 'other-guild'; + await addHandler.run(client, reaction, {id: 'user1'}); + expect(member.roles.add).not.toHaveBeenCalled(); + }); + + test('does nothing for an unconfigured message', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + messageID: 'unknown', + member + }); + await addHandler.run(client, reaction, {id: 'user1'}); + expect(member.roles.add).not.toHaveBeenCalled(); + }); + + test('does nothing for an emoji with no role mapping', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + emoji: '🚫', + member + }); + await addHandler.run(client, reaction, {id: 'user1'}); + expect(member.roles.add).not.toHaveBeenCalled(); + }); + + test('fetches a partial reaction before processing', async () => { + const client = makeClient(config); + const member = makeMember(); + const real = makeReaction({ + emoji: '🔥', + member + }); + const partial = { + partial: true, + fetch: jest.fn().mockResolvedValue(real) + }; + await addHandler.run(client, partial, {id: 'user1'}); + expect(partial.fetch).toHaveBeenCalled(); + expect(member.roles.add).toHaveBeenCalledWith(['role-c']); + }); +}); + +describe('reaction-roles remove handler', () => { + test('removes the comma-split roles for a matching emoji', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + emoji: '👍', + member + }); + await removeHandler.run(client, reaction, {id: 'user1'}); + expect(member.roles.remove).toHaveBeenCalledWith(['role-a', 'role-b']); + }); + + test('does not re-react when removing', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + emoji: '👍', + member + }); + await removeHandler.run(client, reaction, {id: 'user1'}); + expect(reaction.message.react).not.toHaveBeenCalled(); + }); + + test('processes the bot\'s own removal (no self-skip on remove)', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + emoji: '🔥', + member + }); + await removeHandler.run(client, reaction, {id: 'bot1'}); + expect(member.roles.remove).toHaveBeenCalledWith(['role-c']); + }); + + test('does nothing for an unconfigured message', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + messageID: 'unknown', + member + }); + await removeHandler.run(client, reaction, {id: 'user1'}); + expect(member.roles.remove).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/reaction-roles/removeHandler.edge.test.js b/tests/reaction-roles/removeHandler.edge.test.js new file mode 100644 index 00000000..08717e23 --- /dev/null +++ b/tests/reaction-roles/removeHandler.edge.test.js @@ -0,0 +1,113 @@ +/* + * Additional edge-case coverage for the reaction-roles REMOVE handler, which the + * existing reactionHandlers.test.js touches only lightly. Focuses on the guard + * chain that differs from / is shared with the add handler: botReady guard, + * partial fetch, cross-guild guard, and unmapped-emoji guard. + */ +const removeHandler = require('../../modules/reaction-roles/events/messageReactionRemove'); + +function makeMember() { + return { + roles: { + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + } + }; +} + +function makeClient(messages) { + return { + botReadyAt: Date.now(), + guild: {id: 'g1'}, + user: {id: 'bot1'}, + configurations: {'reaction-roles': {messages}} + }; +} + +function makeReaction({ + emoji = '👍', + messageID = 'msg1', + guildId = 'g1', + member = makeMember() + } = {}) { + return { + partial: false, + _emoji: {toString: () => emoji}, + message: { + id: messageID, + guildId, + guild: {members: {fetch: jest.fn().mockResolvedValue(member)}} + } + }; +} + +const config = [{ + messageID: 'msg1', + reactions: { + '👍': 'r1,r2', + '🔥': 'r3' + } +}]; + +test('ignores removals before the bot is ready', async () => { + const client = makeClient(config); + client.botReadyAt = undefined; + const member = makeMember(); + const reaction = makeReaction({member}); + await removeHandler.run(client, reaction, {id: 'u1'}); + expect(reaction.message.guild.members.fetch).not.toHaveBeenCalled(); + expect(member.roles.remove).not.toHaveBeenCalled(); +}); + +test('fetches a partial reaction before processing the removal', async () => { + const client = makeClient(config); + const member = makeMember(); + const real = makeReaction({ + emoji: '🔥', + member + }); + const partial = { + partial: true, + fetch: jest.fn().mockResolvedValue(real) + }; + await removeHandler.run(client, partial, {id: 'u1'}); + expect(partial.fetch).toHaveBeenCalled(); + expect(member.roles.remove).toHaveBeenCalledWith(['r3']); +}); + +test('ignores removals from a different guild', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + guildId: 'elsewhere', + member + }); + await removeHandler.run(client, reaction, {id: 'u1'}); + expect(member.roles.remove).not.toHaveBeenCalled(); +}); + +test('does nothing for an emoji with no role mapping', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + emoji: '🚫', + member + }); + await removeHandler.run(client, reaction, {id: 'u1'}); + expect(member.roles.remove).not.toHaveBeenCalled(); +}); + +test('removes a single role when the mapping has no comma', async () => { + const client = makeClient(config); + const member = makeMember(); + const reaction = makeReaction({ + emoji: '🔥', + member + }); + await removeHandler.run(client, reaction, {id: 'u1'}); + expect(member.roles.remove).toHaveBeenCalledWith(['r3']); +}); + +test('exposes allowPartial = true', () => { + expect(removeHandler.allowPartial).toBe(true); +}); \ No newline at end of file diff --git a/tests/reminders/models.test.js b/tests/reminders/models.test.js new file mode 100644 index 00000000..e3dc69d7 --- /dev/null +++ b/tests/reminders/models.test.js @@ -0,0 +1,42 @@ +/* + * Schema test for the reminders Reminder model. + * + * sequelize is mocked so init() records the schema. We assert the autoIncrement + * PK and the columns the scheduler relies on (userID, reminderText, channelID, + * date), plus the table name / timestamps and loader config. + */ + +jest.mock('sequelize', () => { + const DataTypes = new Proxy({}, {get: (_t, prop) => ({__type: prop})}); + + class Model { + static init(attributes, options) { + this._attributes = attributes; + this._options = options; + return this; + } + } + + return { + DataTypes, + Model + }; +}); + +describe('reminders Reminder model', () => { + test('exposes the scheduling columns with an autoIncrement PK', () => { + const mod = require('../../modules/reminders/models/Reminder'); + mod.init({}); + const a = mod._attributes; + expect(a.id.primaryKey).toBe(true); + expect(a.id.autoIncrement).toBe(true); + expect(Object.keys(a).sort()).toEqual(['channelID', 'date', 'id', 'reminderText', 'userID']); + expect(a.date.__type).toBe('DATE'); + expect(mod._options.tableName).toBe('reminders-reminder'); + expect(mod._options.timestamps).toBe(true); + expect(mod.config).toEqual({ + name: 'Reminder', + module: 'reminders' + }); + }); +}); \ No newline at end of file diff --git a/tests/reminders/notificationButtons.test.js b/tests/reminders/notificationButtons.test.js new file mode 100644 index 00000000..c59e200b --- /dev/null +++ b/tests/reminders/notificationButtons.test.js @@ -0,0 +1,82 @@ +/* + * Extra coverage for planReminder()'s fired notification: it must attach the four + * snooze buttons (10m/30m/1h/1d) with customIds that embed the reminder id, and + * pass the reminder's placeholders to embedType. Complements planReminder.test.js + * (which only checks scheduling + the send target). + * + * node-schedule + helpers are mocked so we can capture the scheduled callback and + * inspect the exact embedType arguments. + */ + +jest.mock('node-schedule', () => ({ + scheduleJob: jest.fn((date, cb) => ({ + date, + cb, + cancel: jest.fn() + })) +})); +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn((tpl, params, opts) => ({ + tpl, + params, + opts + })), + formatDiscordUserName: (u) => (u && u.tag) || 'user' +})); + +const {scheduleJob} = require('node-schedule'); +const helpers = require('../../src/functions/helpers'); +const {planReminder} = require('../../modules/reminders/reminders'); + +beforeEach(() => { + scheduleJob.mockClear(); + helpers.embedType.mockClear(); +}); + +function makeClient(channel, member) { + return { + jobs: [], + guild: { + members: {fetch: jest.fn().mockResolvedValue(member)}, + channels: {cache: {get: jest.fn().mockReturnValue(channel)}} + }, + configurations: {reminders: {config: {notificationMessage: 'Hey %mention%: %message%'}}} + }; +} + +test('the fired notification attaches the four snooze buttons carrying the reminder id', async () => { + const channel = {send: jest.fn().mockResolvedValue()}; + const member = { + user: { + toString: () => '<@u>', + tag: 'U#1', + avatarURL: () => 'a' + } + }; + const client = makeClient(channel, member); + + planReminder(client, { + id: 77, + date: new Date(Date.now() + 1000), + userID: 'u', + reminderText: 'drink water', + channelID: 'chan1' + }); + const cb = scheduleJob.mock.calls[0][1]; + await cb(); + + expect(helpers.embedType).toHaveBeenCalledTimes(1); + const [tpl, params, opts] = helpers.embedType.mock.calls[0]; + expect(tpl).toBe('Hey %mention%: %message%'); + expect(params['%message%']).toBe('drink water'); + + const buttons = opts.components[0].components; + expect(buttons).toHaveLength(4); + const ids = buttons.map(b => b.customId); + expect(ids).toEqual([ + 'reminder-snooze-10m-77', + 'reminder-snooze-30m-77', + 'reminder-snooze-1h-77', + 'reminder-snooze-1d-77' + ]); +}); \ No newline at end of file diff --git a/tests/reminders/planReminder.test.js b/tests/reminders/planReminder.test.js new file mode 100644 index 00000000..f215ca5e --- /dev/null +++ b/tests/reminders/planReminder.test.js @@ -0,0 +1,151 @@ +/* + * Tests for planReminder(): it schedules a node-schedule job for a reminder's + * due date and registers it on client.jobs. It must REFUSE to schedule when the + * date is missing, not a real date, or already in the past (those reminders + * would fire immediately / never), so we assert the guard chain. + * + * node-schedule is mocked so no real timers are created and we can capture the + * scheduled callback for the "fire" path. + */ + +jest.mock('node-schedule', () => ({ + scheduleJob: jest.fn((date, cb) => ({ + date, + cb, + cancel: jest.fn() + })) +})); + +const {scheduleJob} = require('node-schedule'); +const {planReminder} = require('../../modules/reminders/reminders'); + +function makeClient() { + return {jobs: []}; +} + +beforeEach(() => { + scheduleJob.mockClear(); +}); + +describe('planReminder scheduling guards', () => { + test('schedules a job for a future date and tracks it on client.jobs', () => { + const client = makeClient(); + const future = new Date(Date.now() + 60 * 60 * 1000); + planReminder(client, { + id: 1, + date: future, + userID: 'u', + reminderText: 'hi', + channelID: 'c' + }); + expect(scheduleJob).toHaveBeenCalledTimes(1); + expect(scheduleJob.mock.calls[0][0]).toBe(future); + expect(client.jobs).toHaveLength(1); + }); + + test('does not schedule when the date is missing', () => { + const client = makeClient(); + planReminder(client, { + id: 1, + date: null + }); + expect(scheduleJob).not.toHaveBeenCalled(); + expect(client.jobs).toHaveLength(0); + }); + + test('does not schedule when the date is invalid', () => { + const client = makeClient(); + planReminder(client, { + id: 1, + date: new Date('not-a-date') + }); + expect(scheduleJob).not.toHaveBeenCalled(); + expect(client.jobs).toHaveLength(0); + }); + + test('does not schedule a date already in the past', () => { + const client = makeClient(); + const past = new Date(Date.now() - 1000); + planReminder(client, { + id: 1, + date: past + }); + expect(scheduleJob).not.toHaveBeenCalled(); + expect(client.jobs).toHaveLength(0); + }); +}); + +describe('planReminder fire callback', () => { + function makeFireClient(channel, member) { + return { + jobs: [], + guild: { + members: {fetch: jest.fn().mockResolvedValue(member)}, + channels: {cache: {get: jest.fn().mockReturnValue(channel)}} + }, + configurations: {reminders: {config: {notificationMessage: 'You asked: %message%'}}} + }; + } + + test('sends the reminder to the configured guild channel', async () => { + const channel = {send: jest.fn().mockResolvedValue()}; + const member = { + user: { + toString: () => '<@u>', + tag: 'U#1', + avatarURL: () => null + } + }; + const client = makeFireClient(channel, member); + planReminder(client, { + id: 7, + date: new Date(Date.now() + 1000), + userID: 'u', + reminderText: 'water', + channelID: 'chan1' + }); + const cb = scheduleJob.mock.calls[0][1]; + await cb(); + expect(client.guild.members.fetch).toHaveBeenCalledWith('u'); + expect(channel.send).toHaveBeenCalledTimes(1); + }); + + test('sends to a DM channel when channelID is "DM"', async () => { + const dmChannel = {send: jest.fn().mockResolvedValue()}; + const member = { + user: { + toString: () => '<@u>', + tag: 'U#1', + avatarURL: () => null, + createDM: jest.fn().mockResolvedValue(dmChannel) + } + }; + const client = makeFireClient(null, member); + planReminder(client, { + id: 8, + date: new Date(Date.now() + 1000), + userID: 'u', + reminderText: 'water', + channelID: 'DM' + }); + const cb = scheduleJob.mock.calls[0][1]; + await cb(); + expect(member.user.createDM).toHaveBeenCalled(); + expect(dmChannel.send).toHaveBeenCalledTimes(1); + }); + + test('does nothing if the member can no longer be fetched', async () => { + const channel = {send: jest.fn().mockResolvedValue()}; + const client = makeFireClient(channel, null); + planReminder(client, { + id: 9, + date: new Date(Date.now() + 1000), + userID: 'gone', + reminderText: 'x', + channelID: 'chan1' + }); + const cb = scheduleJob.mock.calls[0][1]; + await cb(); + expect(channel.send).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/reminders/reminderCommand.test.js b/tests/reminders/reminderCommand.test.js new file mode 100644 index 00000000..056d1aae --- /dev/null +++ b/tests/reminders/reminderCommand.test.js @@ -0,0 +1,103 @@ +/* + * Tests for the /remind-me command (commands/reminder.js). + * + * Key validation: the requested time must be at least ~1 minute in the future, + * otherwise the command refuses with an ephemeral warning and does NOT persist + * a reminder. On success it creates the Reminder row (DM vs. channel target) and + * schedules it. + * + * parseDuration is ESM-only and requires init(); we mock it to a deterministic + * function. The reminders sibling (node-schedule) is mocked too. + */ + +jest.mock('../../src/functions/parseDuration', () => jest.fn()); +jest.mock('../../modules/reminders/reminders', () => ({planReminder: jest.fn()})); + +const durationParser = require('../../src/functions/parseDuration'); +const {planReminder} = require('../../modules/reminders/reminders'); +const command = require('../../modules/reminders/commands/reminder'); + +function makeInteraction({ + inValue, + what = 'do the thing', + dm = false + } = {}) { + return { + user: {id: 'u1'}, + channel: {id: 'chan1'}, + options: { + getString: jest.fn((name) => (name === 'in' ? inValue : what)), + getBoolean: jest.fn(() => dm) + }, + client: { + models: { + reminders: { + Reminder: {create: jest.fn().mockImplementation((o) => Promise.resolve({id: 5, ...o}))} + } + } + }, + reply: jest.fn().mockResolvedValue() + }; +} + +beforeEach(() => { + durationParser.mockReset(); + planReminder.mockClear(); +}); + +describe('/remind-me validation', () => { + test('refuses a time less than a minute in the future', async () => { + durationParser.mockReturnValue(30 * 1000); // 30s + const interaction = makeInteraction({inValue: '30s'}); + await command.run(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + content: expect.stringContaining('reminders.one-minute-in-future') + }) + ); + expect(interaction.client.models.reminders.Reminder.create).not.toHaveBeenCalled(); + expect(planReminder).not.toHaveBeenCalled(); + }); + + test('refuses an unparseable duration (NaN)', async () => { + durationParser.mockReturnValue(NaN); + const interaction = makeInteraction({inValue: 'gibberish'}); + await command.run(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: expect.stringContaining('reminders.one-minute-in-future')}) + ); + expect(interaction.client.models.reminders.Reminder.create).not.toHaveBeenCalled(); + }); +}); + +describe('/remind-me success path', () => { + test('creates a channel reminder and schedules it', async () => { + durationParser.mockReturnValue(2 * 60 * 1000); // 2 min + const interaction = makeInteraction({ + inValue: '2m', + what: 'standup' + }); + await command.run(interaction); + const createArg = interaction.client.models.reminders.Reminder.create.mock.calls[0][0]; + expect(createArg.userID).toBe('u1'); + expect(createArg.reminderText).toBe('standup'); + expect(createArg.channelID).toBe('chan1'); + expect(createArg.date.getTime()).toBeGreaterThan(Date.now()); + expect(planReminder).toHaveBeenCalledTimes(1); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: expect.stringContaining('reminders.reminder-set')}) + ); + }); + + test('targets DM when the dm option is set', async () => { + durationParser.mockReturnValue(5 * 60 * 1000); + const interaction = makeInteraction({ + inValue: '5m', + dm: true + }); + await command.run(interaction); + const createArg = interaction.client.models.reminders.Reminder.create.mock.calls[0][0]; + expect(createArg.channelID).toBe('DM'); + }); +}); \ No newline at end of file diff --git a/tests/reminders/snoozeInteraction.test.js b/tests/reminders/snoozeInteraction.test.js new file mode 100644 index 00000000..64faf36a --- /dev/null +++ b/tests/reminders/snoozeInteraction.test.js @@ -0,0 +1,148 @@ +/* + * Tests for the reminders snooze button handler (events/interactionCreate.js). + * + * Covered behavior: + * - ignores non-button interactions and non-snooze custom IDs + * - parses the duration key + reminder id out of the custom id + * - rejects unknown durations and reminders owned by a different user + * - creates a NEW reminder offset by the snooze duration, schedules it, + * clears the original message components and confirms ephemerally + * + * The sibling reminders.js (which pulls in node-schedule + helpers) is mocked so + * planReminder is just a spy. + */ + +jest.mock('../../modules/reminders/reminders', () => ({planReminder: jest.fn()})); + +const {planReminder} = require('../../modules/reminders/reminders'); +const handler = require('../../modules/reminders/events/interactionCreate'); + +function makeClient(reminder) { + return { + models: { + reminders: { + Reminder: { + findOne: jest.fn().mockResolvedValue(reminder), + create: jest.fn().mockImplementation((obj) => Promise.resolve({id: 99, ...obj})) + } + } + } + }; +} + +function makeInteraction(customId, userID = 'owner') { + return { + customId, + isButton: () => true, + user: {id: userID}, + reply: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue(), + followUp: jest.fn().mockResolvedValue() + }; +} + +const original = { + id: '42', + userID: 'owner', + reminderText: 'drink water', + channelID: 'chan1' +}; + +beforeEach(() => planReminder.mockClear()); + +describe('reminders snooze handler guards', () => { + test('ignores non-button interactions', async () => { + const client = makeClient(original); + const interaction = { + isButton: () => false, + customId: 'reminder-snooze-10m-42' + }; + await handler.run(client, interaction); + expect(client.models.reminders.Reminder.findOne).not.toHaveBeenCalled(); + }); + + test('ignores buttons with an unrelated custom id', async () => { + const client = makeClient(original); + const interaction = makeInteraction('some-other-button'); + await handler.run(client, interaction); + expect(client.models.reminders.Reminder.findOne).not.toHaveBeenCalled(); + }); + + test('ignores an unknown snooze duration key', async () => { + const client = makeClient(original); + const interaction = makeInteraction('reminder-snooze-99y-42'); + await handler.run(client, interaction); + expect(client.models.reminders.Reminder.findOne).not.toHaveBeenCalled(); + expect(planReminder).not.toHaveBeenCalled(); + }); + + test('rejects snoozing a reminder owned by another user', async () => { + const client = makeClient(original); + const interaction = makeInteraction('reminder-snooze-10m-42', 'someone-else'); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + content: expect.stringContaining('reminders.snooze-not-allowed') + }) + ); + expect(planReminder).not.toHaveBeenCalled(); + }); + + test('rejects when the original reminder no longer exists', async () => { + const client = makeClient(null); + const interaction = makeInteraction('reminder-snooze-10m-42'); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalled(); + expect(planReminder).not.toHaveBeenCalled(); + }); +}); + +describe('reminders snooze handler success path', () => { + test('creates a new reminder offset by the snooze duration and schedules it', async () => { + const client = makeClient(original); + const interaction = makeInteraction('reminder-snooze-30m-42'); + const before = Date.now(); + await handler.run(client, interaction); + const createArg = client.models.reminders.Reminder.create.mock.calls[0][0]; + expect(createArg.userID).toBe('owner'); + expect(createArg.reminderText).toBe('drink water'); + expect(createArg.channelID).toBe('chan1'); + const offset = createArg.date.getTime() - before; + // ~30 minutes (allow scheduling slack) + expect(offset).toBeGreaterThan(30 * 60 * 1000 - 5000); + expect(offset).toBeLessThan(30 * 60 * 1000 + 5000); + expect(planReminder).toHaveBeenCalledTimes(1); + }); + + test('clears the original components and confirms via ephemeral followUp', async () => { + const client = makeClient(original); + const interaction = makeInteraction('reminder-snooze-1d-42'); + await handler.run(client, interaction); + expect(interaction.update).toHaveBeenCalledWith({components: []}); + expect(interaction.followUp).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + content: expect.stringContaining('reminders.snoozed') + }) + ); + }); + + test('maps each duration key to the correct offset', async () => { + const cases = { + '10m': 10 * 60 * 1000, + '1h': 60 * 60 * 1000, + '1d': 24 * 60 * 60 * 1000 + }; + for (const [key, ms] of Object.entries(cases)) { + const client = makeClient(original); + const interaction = makeInteraction(`reminder-snooze-${key}-42`); + const before = Date.now(); + await handler.run(client, interaction); + const createArg = client.models.reminders.Reminder.create.mock.calls[0][0]; + const offset = createArg.date.getTime() - before; + expect(offset).toBeGreaterThan(ms - 5000); + expect(offset).toBeLessThan(ms + 5000); + } + }); +}); \ No newline at end of file diff --git a/tests/rock-paper-scissors/gameLogic.test.js b/tests/rock-paper-scissors/gameLogic.test.js new file mode 100644 index 00000000..f826dc1d --- /dev/null +++ b/tests/rock-paper-scissors/gameLogic.test.js @@ -0,0 +1,223 @@ +/* + * Unit tests for the rock-paper-scissors pure game logic: + * - findWinner(): the win/lose/tie resolution table (rock>scissors>paper>rock) + * - mentionUsers(): who still needs to move (only non-bot, still-pending players) + * - resetGame(): resets per-player state, with the bot pre-"selected" + * + * Localized strings come from the deterministic localize stub, so e.g. + * localize('rock-paper-scissors','won') === 'rock-paper-scissors.won'. moves are + * exported as ['🪨 …stone', '📄 …paper', '✂️ …scissors'] in that order. + */ + +const rps = require('../../modules/rock-paper-scissors/commands/rock-paper-scissors'); + +const [STONE, PAPER, SCISSORS] = rps._moves; +const WON = 'rock-paper-scissors.won'; +const LOST = 'rock-paper-scissors.lost'; +const TIE = 'rock-paper-scissors.tie'; + +describe('rock-paper-scissors findWinner', () => { + test('identical moves are a tie for both players', () => { + expect(rps.findWinner(STONE, STONE)).toEqual({ + win1: TIE, + win2: TIE + }); + expect(rps.findWinner(PAPER, PAPER)).toEqual({ + win1: TIE, + win2: TIE + }); + expect(rps.findWinner(SCISSORS, SCISSORS)).toEqual({ + win1: TIE, + win2: TIE + }); + }); + + test('stone beats scissors', () => { + expect(rps.findWinner(STONE, SCISSORS)).toEqual({ + win1: WON, + win2: LOST + }); + expect(rps.findWinner(SCISSORS, STONE)).toEqual({ + win1: LOST, + win2: WON + }); + }); + + test('paper beats stone', () => { + expect(rps.findWinner(PAPER, STONE)).toEqual({ + win1: WON, + win2: LOST + }); + expect(rps.findWinner(STONE, PAPER)).toEqual({ + win1: LOST, + win2: WON + }); + }); + + test('scissors beats paper', () => { + expect(rps.findWinner(SCISSORS, PAPER)).toEqual({ + win1: WON, + win2: LOST + }); + expect(rps.findWinner(PAPER, SCISSORS)).toEqual({ + win1: LOST, + win2: WON + }); + }); + + test('win/lose is never symmetric across the full matrix', () => { + const all = [STONE, PAPER, SCISSORS]; + for (const a of all) { + for (const b of all) { + const { + win1, + win2 + } = rps.findWinner(a, b); + if (a === b) { + expect(win1).toBe(TIE); + expect(win2).toBe(TIE); + } else { + // exactly one winner, one loser + expect([win1, win2].sort()).toEqual([LOST, WON].sort()); + } + } + } + }); +}); + +describe('rock-paper-scissors mentionUsers', () => { + test('mentions both human players while both are pending', () => { + const game = { + user1: { + id: '1', + bot: false + }, + user2: { + id: '2', + bot: false + }, + state1: 'none', + state2: 'none' + }; + expect(rps.mentionUsers(game)).toBe('<@1> <@2>'); + }); + + test('only mentions the player who has not yet picked', () => { + const game = { + user1: { + id: '1', + bot: false + }, + user2: { + id: '2', + bot: false + }, + state1: 'selected', + state2: 'none' + }; + expect(rps.mentionUsers(game)).toBe('<@2>'); + }); + + test('never mentions a bot opponent', () => { + const game = { + user1: { + id: '1', + bot: false + }, + user2: { + id: 'bot', + bot: true + }, + state1: 'none', + state2: 'none' + }; + expect(rps.mentionUsers(game)).toBe('<@1>'); + }); + + test('returns null when nobody is pending', () => { + const game = { + user1: { + id: '1', + bot: false + }, + user2: { + id: '2', + bot: false + }, + state1: 'selected', + state2: 'selected' + }; + expect(rps.mentionUsers(game)).toBeNull(); + }); +}); + +describe('rock-paper-scissors resetGame', () => { + test('resets both human players to none and clears selections', () => { + const game = { + user1: { + id: '1', + bot: false + }, + user2: { + id: '2', + bot: false + }, + msg: 'm1', + state1: 'selected', + state2: 'selected', + selected1: 'rps_stone', + selected2: 'rps_paper' + }; + rps.resetGame(game); + expect(game.state1).toBe('none'); + expect(game.state2).toBe('none'); + expect(game.selected1).toBeUndefined(); + expect(game.selected2).toBeUndefined(); + // stored back into the games registry under its message id + expect(rps._rpsgames['m1']).toBe(game); + }); + + test('pre-selects the bot opponent so only the human must move', () => { + const game = { + user1: { + id: '1', + bot: false + }, + user2: { + id: 'bot', + bot: true + }, + msg: 'm2', + state1: 'selected', + state2: 'selected' + }; + rps.resetGame(game); + expect(game.state1).toBe('none'); + expect(game.state2).toBe('selected'); + }); + + test('returns two action rows (buttons + player row)', () => { + const game = { + user1: { + id: '1', + bot: false, + tag: 'A#1', + discriminator: '1', + username: 'A' + }, + user2: { + id: '2', + bot: false, + tag: 'B#1', + discriminator: '1', + username: 'B' + }, + msg: 'm3', + state1: 'none', + state2: 'none' + }; + const rows = rps.resetGame(game); + expect(Array.isArray(rows)).toBe(true); + expect(rows).toHaveLength(2); + }); +}); \ No newline at end of file diff --git a/tests/rock-paper-scissors/runFlow.test.js b/tests/rock-paper-scissors/runFlow.test.js new file mode 100644 index 00000000..69595f11 --- /dev/null +++ b/tests/rock-paper-scissors/runFlow.test.js @@ -0,0 +1,199 @@ +/* + * Tests for the rock-paper-scissors command run() orchestration and its + * component collector, complementing gameLogic.test.js (which only covered the + * pure helpers). + * + * Covered: + * - challenging the bot: no human confirmation is requested, the board is + * posted immediately, a game is registered under the message id with the bot + * pre-"selected", and a button collector is created + * - challenging another human: a confirmation prompt is shown; an expired + * confirmation edits the "invite expired" message; a "deny" edits the + * "invite denied" message + * - the collector's "collect" handler: a "play again" press resets the game and + * a human picking against the bot resolves a round and renders the result + * - the collector's "end" handler removes the game from the registry + * + * Math.random is stubbed so the bot's pick is deterministic. + */ + +const rps = require('../../modules/rock-paper-scissors/commands/rock-paper-scissors'); +const [STONE] = rps._moves; + +function makeCollector() { + const handlers = {}; + return { + on: jest.fn((event, cb) => { + handlers[event] = cb; + }), + _handlers: handlers + }; +} + +function makeMessage(id = 'game-msg') { + const collector = makeCollector(); + return { + id, + collector, + update: jest.fn().mockResolvedValue(), + createMessageComponentCollector: jest.fn(() => collector), + awaitMessageComponent: jest.fn() + }; +} + +function makeInteraction({ + member = null, + replyMsg + } = {}) { + return { + user: { + id: 'p1', + toString: () => '<@p1>', + bot: false, + tag: 'P1#1', + username: 'P1', + discriminator: '1' + }, + client: { + user: { + id: 'bot', + toString: () => '<@bot>', + bot: true, + tag: 'Bot#1', + username: 'Bot', + discriminator: '0' + } + }, + options: {getMember: jest.fn(() => member)}, + reply: jest.fn().mockResolvedValue(replyMsg), + update: jest.fn().mockResolvedValue(replyMsg) + }; +} + +afterEach(() => { + // clear the shared games registry between tests + for (const k of Object.keys(rps._rpsgames)) delete rps._rpsgames[k]; + jest.restoreAllMocks(); +}); + +describe('rps run() against the bot', () => { + test('posts the board immediately and registers the game with the bot pre-selected', async () => { + const msg = makeMessage('m-bot'); + const interaction = makeInteraction({ + member: null, + replyMsg: msg + }); + await rps.run(interaction); + // no human confirmation requested + expect(interaction.reply).toHaveBeenCalledTimes(1); + expect(rps._rpsgames['m-bot']).toBeTruthy(); + expect(rps._rpsgames['m-bot'].state2).toBe('selected'); // bot is pre-selected + expect(msg.createMessageComponentCollector).toHaveBeenCalledTimes(1); + }); + + test('end handler removes the game from the registry', async () => { + const msg = makeMessage('m-end'); + const interaction = makeInteraction({ + member: null, + replyMsg: msg + }); + await rps.run(interaction); + expect(rps._rpsgames['m-end']).toBeTruthy(); + msg.collector._handlers.end(); + expect(rps._rpsgames['m-end']).toBeUndefined(); + }); + + test('collect: play-again resets the game state', async () => { + const msg = makeMessage('m-again'); + const interaction = makeInteraction({ + member: null, + replyMsg: msg + }); + await rps.run(interaction); + const game = rps._rpsgames['m-again']; + game.state1 = 'selected'; + game.selected1 = 'rps_stone'; + const press = { + customId: 'rps_playagain', + user: {id: 'p1'}, + message: {id: 'm-again'}, + update: jest.fn().mockResolvedValue() + }; + await msg.collector._handlers.collect(press); + expect(press.update).toHaveBeenCalledTimes(1); + expect(game.state1).toBe('none'); + expect(game.selected1).toBeUndefined(); + }); + + test('collect: a human pick vs the bot resolves the round', async () => { + jest.spyOn(Math, 'random').mockReturnValue(0); // bot always picks moves[0] (stone) + const msg = makeMessage('m-play'); + const interaction = makeInteraction({ + member: null, + replyMsg: msg + }); + await rps.run(interaction); + const press = { + customId: 'rps_stone', + user: {id: 'p1'}, + message: {id: 'm-play'}, + update: jest.fn().mockResolvedValue() + }; + await msg.collector._handlers.collect(press); + // both picked stone -> a tie; update is called to render the result + expect(press.update).toHaveBeenCalled(); + const game = rps._rpsgames['m-play']; + // tie resets the game (both back to a fresh round; bot re-selected) + expect(game.state2).toBe('selected'); + }); +}); + +describe('rps run() against another human', () => { + function humanMember() { + return { + id: 'p2', + toString: () => '<@p2>', + user: { + id: 'p2', + bot: false, + tag: 'P2#1', + username: 'P2', + discriminator: '2' + } + }; + } + + test('edits the "invite expired" message when the confirmation times out', async () => { + const confirmMsg = { + update: jest.fn().mockResolvedValue(), + awaitMessageComponent: jest.fn().mockResolvedValue(undefined) // timed out + }; + const interaction = makeInteraction({ + member: humanMember(), + replyMsg: confirmMsg + }); + await rps.run(interaction); + expect(confirmMsg.update).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('invite-expired') + })); + }); + + test('edits the "invite denied" message when the opponent denies', async () => { + const denied = { + customId: 'deny-invite', + update: jest.fn().mockResolvedValue() + }; + const confirmMsg = { + update: jest.fn().mockResolvedValue(), + awaitMessageComponent: jest.fn().mockResolvedValue(denied) + }; + const interaction = makeInteraction({ + member: humanMember(), + replyMsg: confirmMsg + }); + await rps.run(interaction); + expect(denied.update).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('invite-denied') + })); + }); +}); \ No newline at end of file diff --git a/tests/src-commands/help.test.js b/tests/src-commands/help.test.js new file mode 100644 index 00000000..fd4a6eab --- /dev/null +++ b/tests/src-commands/help.test.js @@ -0,0 +1,460 @@ +/* + * Tests for src/commands/help.js — the /help command. + * + * The command groups the client's commands by module, builds a Components V2 + * overview (module list + a select menu, with pagination when there are more + * than 25 modules), replies, and wires up a component collector whose handlers + * switch between the overview and per-module detail views. + * + * help.js uses the REAL localize, helpers (truncate/formatDate/parseEmbedColor) + * and configuration helpers — all pure enough to run in-process. We mock only + * the Discord client + interaction. We capture the components handed to + * interaction.reply and drive the collector handlers directly. + */ + +const help = require('../../src/commands/help'); + +// Walk a Components V2 ContainerBuilder tree collecting all text-display content. +function collectText(component) { + const out = []; + const data = component.data || component; + const kids = (component.components || data.components || []); + for (const c of kids) { + const cd = c.data || c; + if (typeof cd.content === 'string') out.push(cd.content); + // sections wrap text-display components + if (c.components || cd.components) out.push(...collectText(c)); + // section accessory text lives under .components too in builders + if (c.accessory) { /* thumbnails: no text */ + } + } + return out; +} + +// Flatten all text content across an array of top-level containers. +function allText(components) { + return components.flatMap(collectText).join('\n'); +} + +// Find all custom IDs of action-row components (select menus / buttons). +function collectCustomIds(component) { + const ids = []; + const kids = component.components || (component.data && component.data.components) || []; + for (const c of kids) { + const cd = c.data || c; + if (cd.custom_id) ids.push(cd.custom_id); + if (c.components || cd.components) ids.push(...collectCustomIds(c)); + } + return ids; +} + +function makeModule(name, { + humanReadableName, + description, + enabled = true +} = {}) { + return { + enabled, + config: { + humanReadableName: humanReadableName ?? name, + description: description ?? `${name} desc` + } + }; +} + +function makeClient(overrides = {}) { + return { + locale: 'en', + user: {displayAvatarURL: () => 'https://cdn/avatar.png'}, + readyAt: new Date('2024-01-01T00:00:00Z'), + botReadyAt: new Date('2024-01-01T00:00:05Z'), + scnxSetup: false, + scnxData: {}, + strings: { + helpembed: { + title: 'Help %site%', + description: 'Overview of commands', + build_in: 'Built-in' + }, + putBotInfoOnLastSite: false, + disableHelpEmbedStats: false + }, + modules: { + moderation: makeModule('moderation', {humanReadableName: 'Moderation'}), + tickets: makeModule('tickets', {humanReadableName: 'Tickets'}) + }, + commands: [ + { + name: 'ban', + description: 'Ban a user', + module: 'moderation' + }, + { + name: 'kick', + description: 'Kick a user', + module: 'moderation' + }, + { + name: 'ticket', + description: 'Open a ticket', + module: 'tickets' + }, + { + name: 'ping', + description: 'Pong', + module: null + } + ], + config: {customCommands: []}, + ...overrides + }; +} + +function makeInteraction(client) { + let collector; + const message = { + edit: jest.fn().mockResolvedValue(), + createMessageComponentCollector: jest.fn(() => { + collector = { + handlers: {}, + on(event, cb) { + this.handlers[event] = cb; + return this; + } + }; + return collector; + }) + }; + const interaction = { + client, + user: {id: 'invoker'}, + guild: {name: 'My Guild'}, + reply: jest.fn().mockResolvedValue(message), + _message: message, + get collector() { + return collector; + } + }; + return interaction; +} + +async function runHelp(client) { + const interaction = makeInteraction(client); + await help.run(interaction); + return interaction; +} + +describe('help - config metadata', () => { + test('command name is help', () => { + expect(help.config.name).toBe('help'); + }); + test('has a non-empty description', () => { + expect(typeof help.config.description).toBe('string'); + expect(help.config.description.length).toBeGreaterThan(0); + }); +}); + +describe('help - overview reply', () => { + test('replies once with Components V2 flag set', async () => { + const i = await runHelp(makeClient()); + expect(i.reply).toHaveBeenCalledTimes(1); + const arg = i.reply.mock.calls[0][0]; + expect(arg.flags).toBeDefined(); + expect(arg.fetchReply).toBe(true); + expect(Array.isArray(arg.components)).toBe(true); + expect(arg.components.length).toBeGreaterThan(0); + }); + + test('overview lists each module human-readable name', async () => { + const i = await runHelp(makeClient()); + const text = allText(i.reply.mock.calls[0][0].components); + expect(text).toContain('Moderation'); + expect(text).toContain('Tickets'); + }); + + test('commands without a module appear under the built-in group', async () => { + const i = await runHelp(makeClient()); + const text = allText(i.reply.mock.calls[0][0].components); + expect(text).toContain('Built-in'); + expect(text).toContain('/ping'); + }); + + test('module command names are rendered as slash mentions', async () => { + const i = await runHelp(makeClient()); + const text = allText(i.reply.mock.calls[0][0].components); + expect(text).toContain('/ban'); + expect(text).toContain('/kick'); + expect(text).toContain('/ticket'); + }); + + test('a help-module-select menu is attached', async () => { + const i = await runHelp(makeClient()); + const ids = i.reply.mock.calls[0][0].components.flatMap(collectCustomIds); + expect(ids).toContain('help-module-select'); + }); +}); + +describe('help - module filtering', () => { + test('commands of a disabled module are excluded', async () => { + const client = makeClient(); + client.modules.tickets.enabled = false; + const i = await runHelp(client); + const text = allText(i.reply.mock.calls[0][0].components); + expect(text).not.toContain('/ticket'); + expect(text).toContain('/ban'); + }); + + test('commands whose disabled() returns true are excluded', async () => { + const client = makeClient(); + client.commands.push({ + name: 'secret', + description: 'hidden', + module: 'moderation', + disabled: () => true + }); + const i = await runHelp(client); + const text = allText(i.reply.mock.calls[0][0].components); + expect(text).not.toContain('/secret'); + }); + + test('commands whose disabled() returns false are included', async () => { + const client = makeClient(); + client.commands.push({ + name: 'visible', + description: 'shown', + module: 'moderation', + disabled: () => false + }); + const i = await runHelp(client); + const text = allText(i.reply.mock.calls[0][0].components); + expect(text).toContain('/visible'); + }); +}); + +describe('help - custom commands group', () => { + test('enabled COMMAND-type custom commands form their own group', async () => { + const client = makeClient(); + client.config.customCommands = [ + { + type: 'COMMAND', + enabled: true, + slashCommandName: 'mycmd', + slashCommandDescription: 'A custom command', + slashCommandsOptions: [] + } + ]; + const i = await runHelp(client); + const text = allText(i.reply.mock.calls[0][0].components); + expect(text).toContain('/mycmd'); + }); + + test('disabled or non-COMMAND custom commands are ignored', async () => { + const client = makeClient(); + client.config.customCommands = [ + { + type: 'COMMAND', + enabled: false, + slashCommandName: 'disabledcmd', + slashCommandDescription: 'x' + }, + { + type: 'BUTTON', + enabled: true, + slashCommandName: 'notacmd', + slashCommandDescription: 'x' + } + ]; + const i = await runHelp(client); + const text = allText(i.reply.mock.calls[0][0].components); + expect(text).not.toContain('/disabledcmd'); + expect(text).not.toContain('/notacmd'); + }); + + test('custom command missing a name is skipped', async () => { + const client = makeClient(); + client.config.customCommands = [ + { + type: 'COMMAND', + enabled: true, + slashCommandName: '', + slashCommandDescription: 'x' + } + ]; + const i = await runHelp(client); + // no extra group rendered; still replies fine + expect(i.reply).toHaveBeenCalledTimes(1); + }); +}); + +describe('help - pagination (>25 modules)', () => { + function makeManyModulesClient(count) { + const modules = {}; + const commands = []; + for (let n = 0; n < count; n++) { + const key = `mod${n}`; + modules[key] = makeModule(key, {humanReadableName: `Module ${n}`}); + commands.push({ + name: `cmd${n}`, + description: `d${n}`, + module: key + }); + } + return makeClient({ + modules, + commands + }); + } + + test('with <=25 modules no pagination buttons are present', async () => { + const i = await runHelp(makeManyModulesClient(10)); + const ids = i.reply.mock.calls[0][0].components.flatMap(collectCustomIds); + expect(ids).not.toContain('help-page-next'); + expect(ids).not.toContain('help-page-prev'); + }); + + test('with >25 modules prev/next pagination buttons appear', async () => { + const i = await runHelp(makeManyModulesClient(30)); + const ids = i.reply.mock.calls[0][0].components.flatMap(collectCustomIds); + expect(ids).toContain('help-page-prev'); + expect(ids).toContain('help-page-next'); + }); +}); + +describe('help - info / stats container', () => { + test('omits the info container when both bot-info and stats are suppressed', async () => { + const client = makeClient(); + client.strings.putBotInfoOnLastSite = true; + client.strings.disableHelpEmbedStats = true; + const i = await runHelp(client); + // only the header container remains + expect(i.reply.mock.calls[0][0].components).toHaveLength(1); + }); + + test('includes a second container when stats are enabled', async () => { + const i = await runHelp(makeClient()); + expect(i.reply.mock.calls[0][0].components.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe('help - collector wiring', () => { + test('registers a component collector with collect + end handlers', async () => { + const i = await runHelp(makeClient()); + expect(i._message.createMessageComponentCollector).toHaveBeenCalled(); + expect(typeof i.collector.handlers.collect).toBe('function'); + expect(typeof i.collector.handlers.end).toBe('function'); + }); + + test('collect from a different user is rejected with an ephemeral reply', async () => { + const i = await runHelp(makeClient()); + const sub = { + user: {id: 'someone-else'}, + reply: jest.fn().mockResolvedValue(), + isStringSelectMenu: () => false, + isButton: () => false + }; + await i.collector.handlers.collect(sub); + expect(sub.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + }); + + test('selecting a module updates the message to that module detail view', async () => { + const i = await runHelp(makeClient()); + const sub = { + user: {id: 'invoker'}, + values: ['moderation'], + isStringSelectMenu: () => true, + isButton: () => false, + customId: 'help-module-select', + update: jest.fn().mockResolvedValue() + }; + await i.collector.handlers.collect(sub); + expect(sub.update).toHaveBeenCalledTimes(1); + const text = allText(sub.update.mock.calls[0][0].components); + expect(text).toContain('/ban'); + expect(text).toContain('Ban a user'); + }); + + test('module detail view has a back-to-overview button', async () => { + const i = await runHelp(makeClient()); + const sub = { + user: {id: 'invoker'}, + values: ['tickets'], + isStringSelectMenu: () => true, + isButton: () => false, + customId: 'help-module-select', + update: jest.fn().mockResolvedValue() + }; + await i.collector.handlers.collect(sub); + const ids = sub.update.mock.calls[0][0].components.flatMap(collectCustomIds); + expect(ids).toContain('help-overview'); + }); + + test('the overview button returns to the overview view', async () => { + const i = await runHelp(makeClient()); + const sub = { + user: {id: 'invoker'}, + isStringSelectMenu: () => false, + isButton: () => true, + customId: 'help-overview', + update: jest.fn().mockResolvedValue() + }; + await i.collector.handlers.collect(sub); + const ids = sub.update.mock.calls[0][0].components.flatMap(collectCustomIds); + expect(ids).toContain('help-module-select'); + }); + + test('end handler edits the message back to the overview', async () => { + const i = await runHelp(makeClient()); + i.collector.handlers.end(); + expect(i._message.edit).toHaveBeenCalledTimes(1); + const arg = i._message.edit.mock.calls[0][0]; + expect(Array.isArray(arg.components)).toBe(true); + }); +}); + +describe('help - pagination handlers', () => { + function makeManyModulesClient(count) { + const modules = {}; + const commands = []; + for (let n = 0; n < count; n++) { + const key = `mod${n}`; + modules[key] = makeModule(key); + commands.push({ + name: `cmd${n}`, + description: `d${n}`, + module: key + }); + } + return makeClient({ + modules, + commands + }); + } + + test('next then prev navigate select-menu pages', async () => { + const i = await runHelp(makeManyModulesClient(30)); + + const next = { + user: {id: 'invoker'}, + isStringSelectMenu: () => false, + isButton: () => true, + customId: 'help-page-next', + update: jest.fn().mockResolvedValue() + }; + await i.collector.handlers.collect(next); + // page 2 placeholder should mention (2/2) + const textAfterNext = allText(next.update.mock.calls[0][0].components); + // header is re-rendered; the select placeholder includes page index + expect(next.update).toHaveBeenCalledTimes(1); + expect(textAfterNext).toContain('Module'); + + const prev = { + user: {id: 'invoker'}, + isStringSelectMenu: () => false, + isButton: () => true, + customId: 'help-page-prev', + update: jest.fn().mockResolvedValue() + }; + await i.collector.handlers.collect(prev); + expect(prev.update).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/src-commands/reload.test.js b/tests/src-commands/reload.test.js new file mode 100644 index 00000000..44a72f2a --- /dev/null +++ b/tests/src-commands/reload.test.js @@ -0,0 +1,188 @@ +/* + * Tests for src/commands/reload.js — the /reload command flow. + * + * The command: acknowledges (ephemeral reply), optionally announces to the + * log channel, runs reloadConfig(), and on success edits the reply, re-syncs + * slash commands, then edits the reply again with the result. On failure it + * announces failure and exits the process. + * + * We mock reloadConfig (configuration), syncCommandsIfNeeded (main) and + * formatDiscordUserName (helpers) so we can drive each branch deterministically. + */ + +const mockReloadConfig = jest.fn(); +const mockSyncCommands = jest.fn(); + +jest.mock('../../src/functions/configuration', () => ({ + reloadConfig: (...a) => mockReloadConfig(...a) +})); + +// main is moduleNameMapper'd to the stub; extend the stub with the sync fn. +jest.mock('../__stubs__/main', () => { + const actual = jest.requireActual('../__stubs__/main'); + return { + ...actual, + syncCommandsIfNeeded: (...a) => mockSyncCommands(...a) + }; +}); + +jest.mock('../../src/functions/helpers', () => ({ + formatDiscordUserName: (user) => user.username || user.id +})); + +/* + * reload.js requires '../functions/localize' — a path the global moduleNamemapper + * does not rewrite (it only catches paths containing 'src/functions/localize'), + * so the REAL localize module loads here and produces real English strings. We + * therefore assert against the actual locale text rather than the stub format. + */ + +const reload = require('../../src/commands/reload'); + +function makeInteraction({withLogChannel = true} = {}) { + const logChannel = withLogChannel + ? {send: jest.fn().mockResolvedValue()} + : null; + return { + reply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + user: { + id: 'u1', + username: 'tester' + }, + client: {logChannel} + }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('reload - config metadata', () => { + test('exposes name and a non-empty description', () => { + expect(reload.config.name).toBe('reload'); + expect(typeof reload.config.description).toBe('string'); + expect(reload.config.description.length).toBeGreaterThan(0); + }); + + test('is marked as a restricted command', () => { + expect(reload.config.restricted).toBe(true); + }); +}); + +describe('reload - happy path', () => { + test('acknowledges the interaction ephemerally first', async () => { + mockReloadConfig.mockResolvedValue({modules: 3}); + const i = makeInteraction(); + await reload.run(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({ + ephemeral: true, + content: expect.any(String) + })); + // ephemeral reply happens before reloadConfig resolves + expect(i.reply.mock.invocationCallOrder[0]) + .toBeLessThan(mockReloadConfig.mock.invocationCallOrder[0]); + }); + + test('announces start and success in the log channel', async () => { + mockReloadConfig.mockResolvedValue({}); + const i = makeInteraction(); + await reload.run(i); + const sent = i.client.logChannel.send.mock.calls.map(c => c[0]); + // start announcement (prefixed with the 🔄 emoji) and success (✅) + expect(sent.some(m => m.startsWith('🔄'))).toBe(true); + expect(sent.some(m => m.startsWith('✅'))).toBe(true); + }); + + test('includes the formatted username in the start announcement', async () => { + mockReloadConfig.mockResolvedValue({}); + const i = makeInteraction(); + await reload.run(i); + const startMsg = i.client.logChannel.send.mock.calls[0][0]; + // %tag is interpolated with the formatted username + expect(startMsg).toContain('tester'); + }); + + test('calls reloadConfig with the client', async () => { + mockReloadConfig.mockResolvedValue({}); + const i = makeInteraction(); + await reload.run(i); + expect(mockReloadConfig).toHaveBeenCalledWith(i.client); + }); + + test('syncs commands after a successful reload', async () => { + mockReloadConfig.mockResolvedValue({}); + const i = makeInteraction(); + await reload.run(i); + expect(mockSyncCommands).toHaveBeenCalledTimes(1); + }); + + test('edits the reply twice: syncing notice then the final result', async () => { + mockReloadConfig.mockResolvedValue({foo: 'bar'}); + const i = makeInteraction(); + await reload.run(i); + // two editReply calls during the success branch + expect(i.editReply).toHaveBeenCalledTimes(2); + const last = i.editReply.mock.calls[i.editReply.mock.calls.length - 1][0]; + expect(typeof last).toBe('string'); + expect(last.length).toBeGreaterThan(0); + }); + + test('sync happens between the two editReply calls', async () => { + mockReloadConfig.mockResolvedValue({}); + const i = makeInteraction(); + await reload.run(i); + const firstEdit = i.editReply.mock.invocationCallOrder[0]; + const syncCall = mockSyncCommands.mock.invocationCallOrder[0]; + const lastEdit = i.editReply.mock.invocationCallOrder[1]; + expect(firstEdit).toBeLessThan(syncCall); + expect(syncCall).toBeLessThan(lastEdit); + }); + + test('works without a log channel (no throw)', async () => { + mockReloadConfig.mockResolvedValue({}); + const i = makeInteraction({withLogChannel: false}); + await expect(reload.run(i)).resolves.toBeUndefined(); + expect(mockSyncCommands).toHaveBeenCalled(); + }); +}); + +describe('reload - failure path', () => { + let exitSpy; + beforeEach(() => { + exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { + }); + }); + afterEach(() => { + exitSpy.mockRestore(); + }); + + test('on reloadConfig rejection it announces failure, edits reply and exits', async () => { + mockReloadConfig.mockRejectedValue('boom'); + const i = makeInteraction(); + await reload.run(i); + + const sent = i.client.logChannel.send.mock.calls.map(c => c[0]); + // failure announcement prefixed with the warning emoji + expect(sent.some(m => m.startsWith('⚠️️'))).toBe(true); + // the failure branch edits the reply with a {content} object (the failure + // message). Regression guard: the reason must be interpolated into the + // %r placeholder (the code passes {r: reason}); previously it passed + // {reason}, so %r stayed literal and the cause was never shown. + const editArg = i.editReply.mock.calls.find(c => c[0] && typeof c[0].content === 'string'); + expect(editArg).toBeDefined(); + expect(editArg[0].content).toContain('FAILED'); + expect(editArg[0].content).toContain('boom'); + expect(editArg[0].content).not.toContain('%r'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + test('failure announcement is emitted before the editReply error message', async () => { + mockReloadConfig.mockRejectedValue('boom'); + const i = makeInteraction(); + await reload.run(i); + const failureAnnouncement = i.client.logChannel.send.mock.calls + .find(c => c[0].startsWith('⚠️️')); + expect(failureAnnouncement).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/tests/src-events/botReady.test.js b/tests/src-events/botReady.test.js new file mode 100644 index 00000000..8fda127e --- /dev/null +++ b/tests/src-events/botReady.test.js @@ -0,0 +1,44 @@ +/* + * Tests for src/events/botReady.js. + * + * botReady sets the bot's activity/presence (or clears it when disableStatus is + * set). + */ + +const handler = require('../../src/events/botReady'); + +/** + * Builds a client stub with a spyable user + logger. + * @param {Object} [config] + * @returns {Object} + */ +function makeClient(config = {}) { + return { + config: {user_presence: {activities: [{name: 'hi'}]}, ...config}, + user: {setActivity: jest.fn().mockResolvedValue()}, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn() + } + }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('botReady presence', () => { + test('sets the configured presence when status is not disabled', async () => { + const client = makeClient({disableStatus: false}); + await handler.run(client); + expect(client.user.setActivity).toHaveBeenCalledWith(client.config.user_presence); + }); + + test('clears the activity (null) when disableStatus is true', async () => { + const client = makeClient({disableStatus: true}); + await handler.run(client); + expect(client.user.setActivity).toHaveBeenCalledWith(null); + }); +}); diff --git a/tests/src-events/guildLifecycle.test.js b/tests/src-events/guildLifecycle.test.js new file mode 100644 index 00000000..95e76b69 --- /dev/null +++ b/tests/src-events/guildLifecycle.test.js @@ -0,0 +1,245 @@ +/* + * Tests for the home-guild lifecycle event handlers: + * guildAvailable.js - marks the bot ready once the home guild becomes available + * guildUnavailable.js - clears readiness + reports a core issue (scnx) on outage + * guildDelete.js - the bot was kicked: report/exit, teardown, rejoin listener + * + * scnx-integration and configuration are mocked inline; localize is the real module + * (the handlers require it via '../functions/localize', which jest.config's + * moduleNameMapper does not redirect), so we assert on behavior rather than exact text. + */ + +jest.mock('../../src/functions/scnx-integration', () => ({ + reportIssue: jest.fn().mockResolvedValue() +}), {virtual: true}); +jest.mock('../../src/functions/configuration', () => ({ + reloadConfig: jest.fn().mockResolvedValue() +})); + +const EventEmitter = require('events'); +const scnx = require('../../src/functions/scnx-integration'); +const configuration = require('../../src/functions/configuration'); + +const guildAvailable = require('../../src/events/guildAvailable'); +const guildUnavailable = require('../../src/events/guildUnavailable'); +const guildDelete = require('../../src/events/guildDelete'); + +/** + * Builds an EventEmitter-based client stub with the surface the lifecycle handlers touch. + * @param {Object} [over] + * @returns {Object} + */ +function makeClient(over = {}) { + const client = new EventEmitter(); + Object.assign(client, { + config: {guildID: 'home'}, + botReadyAt: null, + scnxSetup: false, + guild: null, + intervals: [], + jobs: [], + user: {id: 'bot1'}, + sanitizePath: (s) => s, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + fatal: jest.fn(), + debug: jest.fn() + } + }); + return Object.assign(client, over); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('guildAvailable', () => { + test('marks the bot ready and stores the guild for the home guild', async () => { + const client = makeClient(); + const guild = {id: 'home'}; + await guildAvailable.run(client, guild); + expect(client.guild).toBe(guild); + expect(client.botReadyAt).toBeInstanceOf(Date); + expect(client.logger.info).toHaveBeenCalled(); + }); + + test('ignores guilds other than the configured home guild', async () => { + const client = makeClient(); + await guildAvailable.run(client, {id: 'other'}); + expect(client.guild).toBe(null); + expect(client.botReadyAt).toBe(null); + }); + + test('no-ops when the bot is already ready (does not re-store guild)', async () => { + const already = new Date(0); + const client = makeClient({botReadyAt: already}); + await guildAvailable.run(client, {id: 'home'}); + expect(client.botReadyAt).toBe(already); + expect(client.guild).toBe(null); + }); + + test('ignoreBotReadyCheck flag is exported', () => { + expect(guildAvailable.ignoreBotReadyCheck).toBe(true); + }); +}); + +describe('guildUnavailable', () => { + test('clears readiness when the home guild goes unavailable', async () => { + const client = makeClient({botReadyAt: new Date()}); + await guildUnavailable.run(client, {id: 'home'}); + expect(client.botReadyAt).toBe(null); + expect(client.logger.warn).toHaveBeenCalled(); + }); + + test('ignores non-home guilds', async () => { + const ready = new Date(); + const client = makeClient({botReadyAt: ready}); + await guildUnavailable.run(client, {id: 'other'}); + expect(client.botReadyAt).toBe(ready); + }); + + test('no-ops when the bot was never ready', async () => { + const client = makeClient({botReadyAt: null}); + await guildUnavailable.run(client, {id: 'home'}); + expect(scnx.reportIssue).not.toHaveBeenCalled(); + }); + + test('reports a CORE_ISSUE via scnx integration when scnxSetup is on', async () => { + const client = makeClient({ + botReadyAt: new Date(), + scnxSetup: true + }); + await guildUnavailable.run(client, {id: 'home'}); + expect(scnx.reportIssue).toHaveBeenCalledWith(client, { + type: 'CORE_ISSUE', + errorDescription: 'home_guild_unavailable' + }); + }); + + test('does not report when scnxSetup is off', async () => { + const client = makeClient({ + botReadyAt: new Date(), + scnxSetup: false + }); + await guildUnavailable.run(client, {id: 'home'}); + expect(scnx.reportIssue).not.toHaveBeenCalled(); + }); +}); + +describe('guildDelete', () => { + let exitSpy; + beforeEach(() => { + exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { + }); + }); + afterEach(() => { + exitSpy.mockRestore(); + }); + + test('ignores non-home guilds', async () => { + const client = makeClient(); + await guildDelete.run(client, {id: 'other'}); + expect(client.logger.error).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + test('non-scnx setup logs fatal and exits the process', async () => { + const client = makeClient({scnxSetup: false}); + await guildDelete.run(client, {id: 'home'}); + expect(client.logger.fatal).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + // Teardown is short-circuited by the early process.exit return. + expect(scnx.reportIssue).not.toHaveBeenCalled(); + }); + + test('scnx setup reports a CORE_FAILURE with an invite URL containing the bot + guild ids', async () => { + const client = makeClient({ + scnxSetup: true, + intervals: [], + jobs: [] + }); + await guildDelete.run(client, {id: 'home'}); + expect(scnx.reportIssue).toHaveBeenCalledWith(client, expect.objectContaining({ + type: 'CORE_FAILURE', + errorDescription: 'bot_not_on_guild' + })); + const url = scnx.reportIssue.mock.calls[0][1].errorData.inviteURL; + expect(url).toContain('client_id=bot1'); + expect(url).toContain('guild_id=home'); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + test('scnx teardown clears readiness, intervals, jobs and guild reference', async () => { + const clearInt = jest.fn(); + const cancel = jest.fn(); + const client = makeClient({ + scnxSetup: true, + botReadyAt: new Date(), + intervals: [101, 202], + jobs: [{cancel}, null, {cancel}] + }); + const realClear = global.clearInterval; + global.clearInterval = clearInt; + const reloadSpy = jest.fn(); + client.on('configReload', reloadSpy); + try { + await guildDelete.run(client, {id: 'home'}); + } finally { + global.clearInterval = realClear; + } + expect(client.botReadyAt).toBe(null); + expect(reloadSpy).toHaveBeenCalled(); + expect(clearInt).toHaveBeenCalledTimes(2); + expect(client.intervals).toEqual([]); + // null jobs are filtered out; only the two real jobs are cancelled. + expect(cancel).toHaveBeenCalledTimes(2); + expect(client.jobs).toEqual([]); + expect(client.guild).toBe(null); + }); + + test('a guildCreate rejoin listener is registered and reloads config on home rejoin', async () => { + const client = makeClient({scnxSetup: true}); + await guildDelete.run(client, {id: 'home'}); + expect(client.listenerCount('guildCreate')).toBe(1); + + const newGuild = {id: 'home'}; + client.emit('guildCreate', newGuild); + await new Promise(setImmediate); + + expect(client.guild).toBe(newGuild); + expect(configuration.reloadConfig).toHaveBeenCalledWith(client); + // Listener removes itself after the home guild rejoins. + expect(client.listenerCount('guildCreate')).toBe(0); + }); + + test('rejoin listener ignores guildCreate for non-home guilds', async () => { + const client = makeClient({scnxSetup: true}); + await guildDelete.run(client, {id: 'home'}); + + client.emit('guildCreate', {id: 'other'}); + await new Promise(setImmediate); + + expect(configuration.reloadConfig).not.toHaveBeenCalled(); + // Listener stays registered, still waiting for the home guild. + expect(client.listenerCount('guildCreate')).toBe(1); + }); + + test('rejoin reloadConfig failure logs fatal and exits', async () => { + configuration.reloadConfig.mockRejectedValueOnce(new Error('bad config')); + const client = makeClient({scnxSetup: true}); + await guildDelete.run(client, {id: 'home'}); + + client.emit('guildCreate', {id: 'home'}); + await new Promise(setImmediate); + await new Promise(setImmediate); + + expect(client.logger.fatal).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + test('ignoreBotReadyCheck flag is exported', () => { + expect(guildDelete.ignoreBotReadyCheck).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/src-events/interactionCreate.test.js b/tests/src-events/interactionCreate.test.js new file mode 100644 index 00000000..d4f0f4cf --- /dev/null +++ b/tests/src-events/interactionCreate.test.js @@ -0,0 +1,861 @@ +/* + * Tests for the central interaction router (src/events/interactionCreate.js). + * + * The router decides, for every incoming interaction, whether to: reject with a + * startup/wrong-guild warning, delegate to the scnx integration (custom commands, + * select-roles, role buttons), look up a slash command, enforce module/disabled/ + * restricted guards, route autocomplete to the right autoComplete handler, or run + * the command (with subcommand dispatch) and surface execution errors. + * + * scnx-integration is mocked inline so we can assert delegation without loading the + * real integration. localize and main are auto-mapped by jest.config moduleNameMapper. + */ + +/* + * NOTE: the router requires localize via '../functions/localize' (no `src/` + * segment) so jest.config's moduleNameMapper does not redirect it to the stub. + * The real localize therefore loads here; warning-message assertions match on the + * leading ⚠️ marker + ephemeral flag rather than exact localized text, which keeps + * them resilient to wording/translation changes while still asserting the branch. + */ + +jest.mock('../../src/functions/scnx-integration', () => ({ + customCommandInteractionClick: jest.fn().mockResolvedValue('cc-click'), + handleSelectRoles: jest.fn().mockResolvedValue('select-roles'), + handleRoleButton: jest.fn().mockResolvedValue('role-button'), + customCommandSlashInteraction: jest.fn().mockResolvedValue('cc-slash') +}), {virtual: true}); + +const scnx = require('../../src/functions/scnx-integration'); +const handler = require('../../src/events/interactionCreate'); + +/** + * Builds a client stub with the surface the router touches. + * @param {Object} [over] overrides merged onto the base client + * @returns {Object} + */ +function makeClient(over = {}) { + return { + botReadyAt: new Date(), + guild: { + id: 'g1', + name: 'Home' + }, + scnxSetup: false, + config: {botOperators: []}, + modules: {}, + commands: [], + strings: {}, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn() + }, + ...over + }; +} + +/** + * Builds an interaction stub. Type predicates default to false; pass `type` to flip one. + * @param {Object} [opts] + * @returns {Object} + */ +function makeInteraction(opts = {}) { + const { + type = 'command', + customId, + commandName, + guild = { + id: 'g1', + name: 'Home' + }, + options = {}, + client: clientForInteraction = { + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn() + } + } + } = opts; + const i = { + customId, + commandName, + guild, + user: { + id: 'u1', + tag: 'User#0001', + username: 'User', + discriminator: '0001' + }, + options: { + _group: undefined, + _subcommand: undefined, + _hoistedOptions: [], + ...options + }, + client: clientForInteraction, + isAutocomplete: () => type === 'autocomplete', + isButton: () => type === 'button', + isSelectMenu: () => type === 'selectmenu', + isCommand: () => type === 'command', + isModalSubmit: () => type === 'modal', + reply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + respond: jest.fn().mockResolvedValue(), + deferred: false + }; + return i; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('startup guard (botReadyAt unset)', () => { + test('autocomplete gets empty respond before bot ready', async () => { + const client = makeClient({botReadyAt: null}); + const interaction = makeInteraction({type: 'autocomplete'}); + await handler.run(client, interaction); + expect(interaction.respond).toHaveBeenCalledWith({}); + expect(interaction.reply).not.toHaveBeenCalled(); + }); + + test('non-autocomplete gets startup warning reply before bot ready', async () => { + const client = makeClient({botReadyAt: null}); + const interaction = makeInteraction({type: 'command'}); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringMatching(/^⚠️ /), + ephemeral: true + }); + }); +}); + +describe('guild guards', () => { + test('returns silently when interaction has no guild', async () => { + const client = makeClient(); + const interaction = makeInteraction({guild: null}); + const result = await handler.run(client, interaction); + expect(result).toBeUndefined(); + expect(interaction.reply).not.toHaveBeenCalled(); + expect(interaction.respond).not.toHaveBeenCalled(); + }); + + test('wrong-guild autocomplete responds empty', async () => { + const client = makeClient(); + const interaction = makeInteraction({ + type: 'autocomplete', + guild: {id: 'other'} + }); + await handler.run(client, interaction); + expect(interaction.respond).toHaveBeenCalledWith({}); + }); + + test('wrong-guild command replies with wrong-guild warning including guild name', async () => { + const client = makeClient(); + const interaction = makeInteraction({ + type: 'command', + guild: {id: 'other'} + }); + await handler.run(client, interaction); + // Real localize interpolates the guild name into the message. + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('Home'), + ephemeral: true + }); + expect(interaction.reply.mock.calls[0][0].content).toMatch(/^⚠️ /); + }); +}); + +describe('scnx delegation by customId', () => { + test('cc- prefixed customId routes to customCommandInteractionClick when scnxSetup', async () => { + const client = makeClient({scnxSetup: true}); + const interaction = makeInteraction({ + type: 'button', + customId: 'cc-foo' + }); + const result = await handler.run(client, interaction); + expect(scnx.customCommandInteractionClick).toHaveBeenCalledWith(interaction); + expect(result).toBe('cc-click'); + }); + + test('cc- prefixed customId is NOT delegated when scnxSetup is false', async () => { + const client = makeClient({scnxSetup: false}); + const interaction = makeInteraction({ + type: 'button', + customId: 'cc-foo' + }); + await handler.run(client, interaction); + expect(scnx.customCommandInteractionClick).not.toHaveBeenCalled(); + }); + + test('select-roles select menu routes to handleSelectRoles', async () => { + const client = makeClient({scnxSetup: true}); + const interaction = makeInteraction({ + type: 'selectmenu', + customId: 'select-roles-1' + }); + const result = await handler.run(client, interaction); + expect(scnx.handleSelectRoles).toHaveBeenCalledWith(client, interaction); + expect(result).toBe('select-roles'); + }); + + test('select-roles-apply button routes to handleSelectRoles', async () => { + const client = makeClient({scnxSetup: true}); + const interaction = makeInteraction({ + type: 'button', + customId: 'select-roles-apply' + }); + await handler.run(client, interaction); + expect(scnx.handleSelectRoles).toHaveBeenCalledWith(client, interaction); + }); + + test('select-roles-cancel button routes to handleSelectRoles', async () => { + const client = makeClient({scnxSetup: true}); + const interaction = makeInteraction({ + type: 'button', + customId: 'select-roles-cancel' + }); + await handler.run(client, interaction); + expect(scnx.handleSelectRoles).toHaveBeenCalledWith(client, interaction); + }); + + test('srb- prefixed button routes to handleRoleButton', async () => { + const client = makeClient({scnxSetup: true}); + const interaction = makeInteraction({ + type: 'button', + customId: 'srb-123' + }); + const result = await handler.run(client, interaction); + expect(scnx.handleRoleButton).toHaveBeenCalledWith(client, interaction); + expect(result).toBe('role-button'); + }); + + test('unrelated button with no commandName returns silently', async () => { + const client = makeClient({scnxSetup: true}); + const interaction = makeInteraction({ + type: 'button', + customId: 'something-else' + }); + const result = await handler.run(client, interaction); + expect(result).toBeUndefined(); + expect(scnx.handleSelectRoles).not.toHaveBeenCalled(); + expect(scnx.handleRoleButton).not.toHaveBeenCalled(); + expect(interaction.reply).not.toHaveBeenCalled(); + }); +}); + +describe('command lookup', () => { + test('missing command on scnx setup delegates to customCommandSlashInteraction', async () => { + const client = makeClient({ + scnxSetup: true, + commands: [] + }); + const interaction = makeInteraction({ + type: 'command', + commandName: 'ghost' + }); + const result = await handler.run(client, interaction); + expect(scnx.customCommandSlashInteraction).toHaveBeenCalledWith(interaction); + expect(result).toBe('cc-slash'); + }); + + test('missing command without scnx replies not-found', async () => { + const client = makeClient({ + scnxSetup: false, + commands: [] + }); + const interaction = makeInteraction({ + type: 'command', + commandName: 'ghost' + }); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringMatching(/^⚠️ /), + ephemeral: true + }); + }); + + test('command lookup is case-insensitive', async () => { + const run = jest.fn().mockResolvedValue('ran'); + const command = { + name: 'Ping', + options: [], + run + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'pInG' + }); + const result = await handler.run(client, interaction); + expect(run).toHaveBeenCalledWith(interaction); + expect(result).toBe('ran'); + }); +}); + +describe('module / disabled guards', () => { + test('command from disabled module without scnx replies module-disabled', async () => { + const command = { + name: 'x', + module: 'fun', + options: [], + run: jest.fn() + }; + const client = makeClient({ + commands: [command], + modules: {fun: {enabled: false}} + }); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + ephemeral: true, + content: expect.stringMatching(/^⚠️ /) + }); + // The disabled module name is interpolated into the message. + expect(interaction.reply.mock.calls[0][0].content).toContain('fun'); + expect(command.run).not.toHaveBeenCalled(); + }); + + test('command from disabled module with scnx delegates to custom command handler', async () => { + const command = { + name: 'x', + module: 'fun', + options: [], + run: jest.fn() + }; + const client = makeClient({ + scnxSetup: true, + commands: [command], + modules: {fun: {enabled: false}} + }); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + await handler.run(client, interaction); + expect(scnx.customCommandSlashInteraction).toHaveBeenCalledWith(interaction); + }); + + test('disabled()-function command replies command-disabled', async () => { + const command = { + name: 'x', + options: [], + run: jest.fn(), + disabled: () => true + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + ephemeral: true, + content: expect.stringMatching(/^⚠️ /) + }); + expect(command.run).not.toHaveBeenCalled(); + }); + + test('disabled()-function command in autocomplete responds with empty array', async () => { + const command = { + name: 'x', + options: [], + disabled: () => true, + autoComplete: {} + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'autocomplete', + commandName: 'x' + }); + await handler.run(client, interaction); + expect(interaction.respond).toHaveBeenCalledWith([]); + }); + + test('disabled() receiving false does not block execution', async () => { + const run = jest.fn().mockResolvedValue(); + const command = { + name: 'x', + options: [], + run, + disabled: () => false + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + await handler.run(client, interaction); + expect(run).toHaveBeenCalled(); + }); +}); + +describe('lazy options resolution', () => { + test('function options are resolved via command.options(client)', async () => { + const optionsFn = jest.fn().mockResolvedValue([]); + const run = jest.fn().mockResolvedValue(); + const command = { + name: 'x', + options: optionsFn, + run + }; + const interactionClient = {marker: true}; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x', + client: interactionClient + }); + await handler.run(client, interaction); + expect(optionsFn).toHaveBeenCalledWith(interactionClient); + expect(run).toHaveBeenCalled(); + }); +}); + +describe('autocomplete routing', () => { + test('no focused option responds empty object', async () => { + const command = { + name: 'x', + options: [], + autoComplete: {} + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'autocomplete', + commandName: 'x', + options: { + _hoistedOptions: [{ + name: 'q', + value: 'abc', + focused: false + }] + } + }); + await handler.run(client, interaction); + expect(interaction.respond).toHaveBeenCalledWith({}); + }); + + test('flat command routes focused option to autoComplete[name]', async () => { + const acFn = jest.fn().mockResolvedValue('ac'); + const command = { + name: 'x', + options: [], + autoComplete: {q: acFn} + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'autocomplete', + commandName: 'x', + options: { + _hoistedOptions: [{ + name: 'q', + value: 'typed', + focused: true + }] + } + }); + await handler.run(client, interaction); + expect(acFn).toHaveBeenCalledWith(interaction); + expect(interaction.value).toBe('typed'); + }); + + test('subcommand-bearing command routes via autoComplete[subCommand][name]', async () => { + const acFn = jest.fn().mockResolvedValue('ac'); + const command = { + name: 'x', + options: [{ + type: 'SUB_COMMAND', + name: 'sub' + }], + autoComplete: {sub: {q: acFn}} + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'autocomplete', + commandName: 'x', + options: { + _subcommand: 'sub', + _hoistedOptions: [{ + name: 'q', + value: 't', + focused: true + }] + } + }); + await handler.run(client, interaction); + expect(acFn).toHaveBeenCalledWith(interaction); + }); + + test('group+subcommand routes via autoComplete[group][subCommand][name]', async () => { + const acFn = jest.fn().mockResolvedValue('ac'); + const command = { + name: 'x', + options: [{ + type: 'SUB_COMMAND', + name: 'sub' + }], + autoComplete: {grp: {sub: {q: acFn}}} + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'autocomplete', + commandName: 'x', + options: { + _group: 'grp', + _subcommand: 'sub', + _hoistedOptions: [{ + name: 'q', + value: 't', + focused: true + }] + } + }); + await handler.run(client, interaction); + expect(acFn).toHaveBeenCalledWith(interaction); + }); + + test('autocomplete handler throwing is caught, logged, and responds with empty array', async () => { + const boom = new Error('ac fail'); + const command = { + name: 'x', + module: 'mod', + options: [], + autoComplete: {q: jest.fn().mockRejectedValue(boom)} + }; + const client = makeClient({ + commands: [command], + modules: {mod: {enabled: true}} + }); + const interaction = makeInteraction({ + type: 'autocomplete', + commandName: 'x', + options: { + _hoistedOptions: [{ + name: 'q', + value: 't', + focused: true + }] + } + }); + await handler.run(client, interaction); + expect(interaction.client.logger.error).toHaveBeenCalled(); + expect(interaction.respond).toHaveBeenCalledWith([]); + }); + + test('autocomplete throw reports to captureException when available', async () => { + const boom = new Error('ac fail'); + const captureException = jest.fn().mockReturnValue('sentry-1'); + const command = { + name: 'x', + module: 'mod', + options: [], + autoComplete: {q: jest.fn().mockRejectedValue(boom)} + }; + const client = makeClient({ + commands: [command], + captureException, + modules: {mod: {enabled: true}} + }); + const interaction = makeInteraction({ + type: 'autocomplete', + commandName: 'x', + options: { + _hoistedOptions: [{ + name: 'q', + value: 't', + focused: true + }] + } + }); + await handler.run(client, interaction); + expect(captureException).toHaveBeenCalledWith(boom, expect.objectContaining({ + command: 'x', + module: 'mod', + focusedOption: 'q', + userID: 'u1' + })); + }); +}); + +describe('restricted commands', () => { + test('non-operator is rejected with permissions message', async () => { + const run = jest.fn(); + const command = { + name: 'x', + options: [], + run, + restricted: true + }; + const client = makeClient({ + commands: [command], + config: {botOperators: ['admin']} + }); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + await handler.run(client, interaction); + expect(run).not.toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalled(); + // embedType wraps the string; ephemeral should be preserved. + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.ephemeral).toBe(true); + }); + + test('operator passes the restricted check and runs', async () => { + const run = jest.fn().mockResolvedValue(); + const command = { + name: 'x', + options: [], + run, + restricted: true + }; + const client = makeClient({ + commands: [command], + config: {botOperators: ['u1']} + }); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + await handler.run(client, interaction); + expect(run).toHaveBeenCalledWith(interaction); + }); +}); + +describe('command execution + subcommand dispatch', () => { + test('flat command (no subcommands) calls run directly', async () => { + const run = jest.fn().mockResolvedValue('ok'); + const command = { + name: 'x', + options: [], + run + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + const result = await handler.run(client, interaction); + expect(run).toHaveBeenCalledWith(interaction); + expect(result).toBe('ok'); + }); + + test('subcommand command without subcommands handler errors out', async () => { + const command = { + name: 'x', + options: [{ + type: 'SUB_COMMAND', + name: 'sub' + }] + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x', + client: {logger: {error: jest.fn()}}, + options: {_subcommand: 'sub'} + }); + await handler.run(client, interaction); + expect(interaction.client.logger.error).toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + }); + + test('subcommand dispatches to subcommands[subCommand]', async () => { + const subFn = jest.fn().mockResolvedValue(); + const command = { + name: 'x', + options: [{ + type: 'SUB_COMMAND', + name: 'sub' + }], + subcommands: {sub: subFn} + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x', + options: {_subcommand: 'sub'} + }); + await handler.run(client, interaction); + expect(subFn).toHaveBeenCalledWith(interaction); + }); + + test('group+subcommand dispatches to subcommands[group][subCommand]', async () => { + const subFn = jest.fn().mockResolvedValue(); + const command = { + name: 'x', + options: [{ + type: 'SUB_COMMAND_GROUP', + name: 'grp' + }], + subcommands: {grp: {sub: subFn}} + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x', + options: { + _group: 'grp', + _subcommand: 'sub' + } + }); + await handler.run(client, interaction); + expect(subFn).toHaveBeenCalledWith(interaction); + }); + + test('beforeSubcommand runs before the subcommand handler', async () => { + const order = []; + const command = { + name: 'x', + options: [{ + type: 'SUB_COMMAND', + name: 'sub' + }], + beforeSubcommand: jest.fn(async () => order.push('before')), + subcommands: {sub: jest.fn(async () => order.push('sub'))} + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x', + options: {_subcommand: 'sub'} + }); + await handler.run(client, interaction); + expect(order).toEqual(['before', 'sub']); + }); + + test('command.run runs after the subcommand when both present', async () => { + const order = []; + const command = { + name: 'x', + options: [{ + type: 'SUB_COMMAND', + name: 'sub' + }], + subcommands: {sub: jest.fn(async () => order.push('sub'))}, + run: jest.fn(async () => order.push('run')) + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x', + options: {_subcommand: 'sub'} + }); + await handler.run(client, interaction); + expect(order).toEqual(['sub', 'run']); + }); +}); + +describe('execution error handling', () => { + test('error on non-deferred interaction replies with execution-failed message', async () => { + const boom = new Error('kaboom'); + const command = { + name: 'x', + options: [], + run: jest.fn().mockRejectedValue(boom) + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + interaction.deferred = false; + await handler.run(client, interaction); + expect(interaction.client.logger.error).toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + expect(interaction.editReply).not.toHaveBeenCalled(); + }); + + test('error on deferred interaction uses editReply', async () => { + const boom = new Error('kaboom'); + const command = { + name: 'x', + options: [], + run: jest.fn().mockRejectedValue(boom) + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + interaction.deferred = true; + await handler.run(client, interaction); + expect(interaction.editReply).toHaveBeenCalled(); + expect(interaction.reply).not.toHaveBeenCalled(); + }); + + test('execution error reports to captureException and stores trace id', async () => { + const boom = new Error('kaboom'); + const captureException = jest.fn().mockReturnValue('trace-9'); + const command = { + name: 'x', + module: 'm', + options: [], + run: jest.fn().mockRejectedValue(boom) + }; + const client = makeClient({ + commands: [command], + captureException, + modules: {m: {enabled: true}} + }); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + await handler.run(client, interaction); + expect(captureException).toHaveBeenCalledWith(boom, expect.objectContaining({ + command: 'x', + module: 'm', + userID: 'u1' + })); + }); + + test('reply failure during error handling is swallowed (no throw)', async () => { + const boom = new Error('kaboom'); + const command = { + name: 'x', + options: [], + run: jest.fn().mockRejectedValue(boom) + }; + const client = makeClient({commands: [command]}); + const interaction = makeInteraction({ + type: 'command', + commandName: 'x' + }); + interaction.reply = jest.fn().mockRejectedValue(new Error('Unknown interaction')); + await expect(handler.run(client, interaction)).resolves.toBeUndefined(); + }); +}); + +describe('non-command, non-autocomplete interactions', () => { + test('a button matching a command name but not isCommand returns before run', async () => { + const run = jest.fn(); + const command = { + name: 'x', + options: [], + run + }; + const client = makeClient({commands: [command]}); + // type modal => isCommand false, isAutocomplete false + const interaction = makeInteraction({ + type: 'modal', + commandName: 'x' + }); + await handler.run(client, interaction); + expect(run).not.toHaveBeenCalled(); + }); +}); + +describe('module export flags', () => { + test('ignoreBotReadyCheck is set so the dispatcher still invokes before bot ready', () => { + expect(handler.ignoreBotReadyCheck).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/src-models/models.test.js b/tests/src-models/models.test.js new file mode 100644 index 00000000..fd87c010 --- /dev/null +++ b/tests/src-models/models.test.js @@ -0,0 +1,131 @@ +/* + * Tests for the pure, declarative parts of the sequelize models: + * DatabaseSchemeVersion, ChannelLock. + * + * These models extend sequelize's Model and define their schema inside a + * static init() that calls super.init(attributes, options). We mock the + * `sequelize` package so that: + * - Model.init captures the (attributes, options) it was handed, and + * - DataTypes are simple sentinel objects we can assert identity against. + * This lets us assert field definitions, primary keys, defaults and table + * options without a real database. + */ + +const captured = {}; + +jest.mock('sequelize', () => { + const DataTypes = { + STRING: {key: 'STRING'}, + INTEGER: {key: 'INTEGER'}, + JSON: {key: 'JSON'}, + DATE: {key: 'DATE'} + }; + + class Model { + static init(attributes, options) { + // Record what this concrete model defined, keyed by class name. + captured[this.name] = { + attributes, + options + }; + return this; + } + } + + return { + DataTypes, + Model + }; +}); + +const {DataTypes} = require('sequelize'); + +const DatabaseSchemeVersion = require('../../src/models/DatabaseSchemeVersion'); +const ChannelLock = require('../../src/models/ChannelLock'); + +// A stand-in sequelize instance; the models only forward it into options. +const fakeSequelize = {dialect: 'sqlite'}; + +function define(model) { + return model.init(fakeSequelize); +} + +describe('models - exported shape', () => { + test.each([ + ['DatabaseSchemeVersion', DatabaseSchemeVersion], + ['ChannelLock', ChannelLock] + ])('%s exports a config.name matching the model', (name, model) => { + expect(model.config).toBeDefined(); + expect(model.config.name).toBe(name); + }); + + test.each([ + DatabaseSchemeVersion, + ChannelLock + ])('model is a class with a static init', (model) => { + expect(typeof model).toBe('function'); + expect(typeof model.init).toBe('function'); + }); + + test('init returns the model class (chainable)', () => { + expect(define(ChannelLock)).toBe(ChannelLock); + }); +}); + +describe('models - DatabaseSchemeVersion schema', () => { + let attrs, opts; + beforeAll(() => { + define(DatabaseSchemeVersion); + ({ + attributes: attrs, + options: opts + } = captured.DatabaseSchemeVersion); + }); + + test('model is a STRING primary key', () => { + expect(attrs.model).toEqual({ + type: DataTypes.STRING, + primaryKey: true + }); + }); + + test('version is a plain STRING', () => { + expect(attrs.version).toBe(DataTypes.STRING); + }); + + test('uses the system_ table prefix with timestamps', () => { + expect(opts.tableName).toBe('system_DatabaseSchemeVersion'); + expect(opts.timestamps).toBe(true); + }); +}); + +describe('models - ChannelLock schema', () => { + let attrs, opts; + beforeAll(() => { + define(ChannelLock); + ({ + attributes: attrs, + options: opts + } = captured.ChannelLock); + }); + + test('id is a STRING primary key', () => { + expect(attrs.id).toEqual({ + type: DataTypes.STRING, + primaryKey: true + }); + }); + + test('permissions is JSON', () => { + expect(attrs.permissions).toBe(DataTypes.JSON); + }); + + test('lockReason is STRING', () => { + expect(attrs.lockReason).toBe(DataTypes.STRING); + }); + + test('table name and timestamps', () => { + expect(opts.tableName).toBe('system_ChannelLock'); + expect(opts.timestamps).toBe(true); + }); +}); diff --git a/tests/staff-management-system/activityChecks.test.js b/tests/staff-management-system/activityChecks.test.js new file mode 100644 index 00000000..1b8f939e --- /dev/null +++ b/tests/staff-management-system/activityChecks.test.js @@ -0,0 +1,310 @@ +/* + * Behavior tests for the activity-check engine in staff-management.js: + * + * - startActivityCheck(): refuses when one is already ACTIVE, when no target + * roles resolve, and when no channel can be resolved; on success it posts the + * check message, persists an ActivityCheck row and (manual mode) confirms, + * and tags initiatorId/isAutomated correctly for automated vs manual runs + * - endActivityCheckProcess(): marks the check ENDED, partitions the expected + * members into responded / exceptions (per exceptionsType) / failed, and posts + * the result embed to the log channel + * - initActivityCheckAutomation(): no-ops when disabled, builds the right cron + * string for Weekly/Monthly, and cancels a pre-existing job before scheduling + * + * node-schedule + helpers are mocked; discord.js builders are real via the shim. + */ + +jest.mock('../../src/functions/helpers', () => ({ + embedTypeV2: jest.fn().mockResolvedValue({ + content: 'rendered', + embeds: [] + }), + safeSetFooter: jest.fn((embed) => embed), + dateToDiscordTimestamp: jest.fn(() => '') +})); +jest.mock('node-schedule', () => ({ + scheduledJobs: {}, + scheduleJob: jest.fn((...args) => ({ + args, + cancel: jest.fn() + })) +})); + +const schedule = require('node-schedule'); +const mgmt = require('../../modules/staff-management-system/staff-management'); + +function modelStub(methods = {}) { + return { + findOne: jest.fn().mockResolvedValue(null), + findAll: jest.fn().mockResolvedValue([]), + findByPk: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ + id: 1, + update: jest.fn().mockResolvedValue() + }), + update: jest.fn().mockResolvedValue(), + ...methods + }; +} + +function makeClient(models = {}, configs = {}) { + return { + guildID: 'g1', + strings: {footer: 'f'}, + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn() + }, + guilds: {cache: {get: jest.fn().mockReturnValue(null)}}, + models: { + 'staff-management-system': { + ActivityCheck: modelStub(), + ActivityCheckResponse: modelStub(), + StaffProfile: modelStub(), + ...models + } + }, + configurations: { + 'staff-management-system': { + 'activity-checks': { + timeframe: 24, + checkMessage: 'check', + targetRoles: ['staff'], ...configs['activity-checks'] + }, + configuration: configs.configuration || {staffRoles: ['staff']} + } + } + }; +} + +beforeEach(() => { + schedule.scheduleJob.mockClear(); + schedule.scheduledJobs = {}; +}); + +describe('startActivityCheck guards', () => { + test('refuses to start when one is already ACTIVE', async () => { + const client = makeClient({ActivityCheck: modelStub({findOne: jest.fn().mockResolvedValue({id: 1})})}); + const interaction = { + editReply: jest.fn().mockResolvedValue(), + options: {getChannel: () => null}, + guild: {channels: {cache: {get: () => null}}}, + channel: {id: 'c'} + }; + await mgmt.startActivityCheck(client, interaction, false); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-ac-act') + })); + }); + + test('refuses when no target roles can be resolved', async () => { + const client = makeClient({}, { + 'activity-checks': {targetRoles: []}, + configuration: {staffRoles: []} + }); + const interaction = { + editReply: jest.fn().mockResolvedValue(), + options: {getChannel: () => null}, + guild: {channels: {cache: {get: () => null}}}, + channel: null + }; + await mgmt.startActivityCheck(client, interaction, false); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-ac-norole') + })); + }); + + test('refuses when no channel can be resolved', async () => { + const client = makeClient(); + const interaction = { + editReply: jest.fn().mockResolvedValue(), + options: {getChannel: () => null}, + guild: {channels: {cache: {get: () => null}}}, + channel: null + }; + await mgmt.startActivityCheck(client, interaction, false); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-ac-invchan') + })); + }); +}); + +describe('startActivityCheck success', () => { + test('manual run posts the check, persists a row and confirms', async () => { + const create = jest.fn().mockResolvedValue({id: 5}); + const client = makeClient({ActivityCheck: modelStub({create})}); + const channel = { + id: 'ac-chan', + send: jest.fn().mockResolvedValue({id: 'check-msg'}) + }; + const interaction = { + user: { + id: 'mod', + toString: () => '<@mod>' + }, + editReply: jest.fn().mockResolvedValue(), + options: {getChannel: () => channel}, + guild: {channels: {cache: {get: () => channel}}}, + channel + }; + await mgmt.startActivityCheck(client, interaction, false); + expect(channel.send).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + channelId: 'ac-chan', + status: 'ACTIVE', + initiatorId: 'mod', + isAutomated: false + })); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('succ-ac-start') + })); + }); + + test('automated run targets the passed channel with a null initiator', async () => { + const create = jest.fn().mockResolvedValue({id: 6}); + const client = makeClient({ActivityCheck: modelStub({create})}); + const channel = { + id: 'auto-chan', + send: jest.fn().mockResolvedValue({id: 'check-msg'}) + }; + await mgmt.startActivityCheck(client, channel, true); + expect(channel.send).toHaveBeenCalled(); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + channelId: 'auto-chan', + initiatorId: null, + isAutomated: true + })); + }); +}); + +describe('endActivityCheckProcess', () => { + function memberCollection(members) { + return { + filter(fn) { + return memberCollection(members.filter(fn)); + }, + forEach(fn) { + members.forEach(fn); + }, + keys() { + return members.map(m => m.id); + }, + get size() { + return members.length; + } + }; + } + + test('marks the check ENDED and posts a partitioned result embed', async () => { + const activeCheck = { + id: 9, + channelId: 'ac-chan', + messageId: 'check-msg', + targetRoles: JSON.stringify(['staff']), + isAutomated: false, + initiatorId: 'mod', + update: jest.fn().mockResolvedValue() + }; + const logChannel = {send: jest.fn().mockResolvedValue()}; + const members = [ + { + id: 'responded1', + user: {bot: false}, + roles: {cache: {some: () => true}} + }, + { + id: 'failed1', + user: {bot: false}, + roles: {cache: {some: () => true}} + } + ]; + const guild = { + channels: {cache: {get: jest.fn((id) => (id === 'ac-chan' ? {messages: {fetch: jest.fn().mockResolvedValue(null)}} : logChannel))}}, + members: {cache: memberCollection(members)} + }; + const client = makeClient({ + ActivityCheckResponse: modelStub({findAll: jest.fn().mockResolvedValue([{userId: 'responded1'}])}), + StaffProfile: modelStub({findAll: jest.fn().mockResolvedValue([])}) + }, {'activity-checks': {logChannel: 'log-chan'}}); + client.guilds.cache.get = jest.fn().mockReturnValue(guild); + + await mgmt.endActivityCheckProcess(client, activeCheck); + expect(activeCheck.update).toHaveBeenCalledWith({status: 'ENDED'}); + expect(logChannel.send).toHaveBeenCalledTimes(1); + const embed = logChannel.send.mock.calls[0][0].embeds[0]; + // responded field lists responded1, failed field lists failed1 + const fieldValues = embed.fields.map(f => f.value).join(' '); + expect(fieldValues).toContain('<@responded1>'); + expect(fieldValues).toContain('<@failed1>'); + }); + + test('bails (only flips status) when there is no guild', async () => { + const activeCheck = { + id: 1, + update: jest.fn().mockResolvedValue(), + targetRoles: '[]' + }; + const client = makeClient(); + client.guilds.cache.get = jest.fn().mockReturnValue(null); + await mgmt.endActivityCheckProcess(client, activeCheck); + expect(activeCheck.update).toHaveBeenCalledWith({status: 'ENDED'}); + }); +}); + +describe('initActivityCheckAutomation', () => { + test('does nothing when automation is disabled', () => { + const client = makeClient({}, { + 'activity-checks': { + enableActivityChecks: false, + automatedChecks: false + } + }); + mgmt.initActivityCheckAutomation(client); + expect(schedule.scheduleJob).not.toHaveBeenCalled(); + }); + + test('schedules a weekly cron on the configured weekday', () => { + const client = makeClient({}, { + 'activity-checks': { + enableActivityChecks: true, + automatedChecks: true, + automatedCheckInterval: 'Weekly', + automatedCheckWeekDay: 'Wednesday' + } + }); + mgmt.initActivityCheckAutomation(client); + expect(schedule.scheduleJob).toHaveBeenCalledTimes(1); + const [name, cron] = schedule.scheduleJob.mock.calls[0]; + expect(name).toBe('automated-activity-check'); + expect(cron).toBe('0 12 * * 3'); // Wednesday = 3 + }); + + test('uses a literal cron string when interval is Cronjob', () => { + const client = makeClient({}, { + 'activity-checks': { + enableActivityChecks: true, + automatedChecks: true, + automatedCheckInterval: 'Cronjob', + automatedCheckCronjob: '0 0 * * 0' + } + }); + mgmt.initActivityCheckAutomation(client); + expect(schedule.scheduleJob.mock.calls[0][1]).toBe('0 0 * * 0'); + }); + + test('cancels an existing job before scheduling a new one', () => { + const cancel = jest.fn(); + schedule.scheduledJobs['automated-activity-check'] = {cancel}; + const client = makeClient({}, { + 'activity-checks': { + enableActivityChecks: true, + automatedChecks: true, + automatedCheckInterval: 'Weekly', + automatedCheckWeekDay: 'Monday' + } + }); + mgmt.initActivityCheckAutomation(client); + expect(cancel).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/staff-management-system/commandWiring.test.js b/tests/staff-management-system/commandWiring.test.js new file mode 100644 index 00000000..72ca37eb --- /dev/null +++ b/tests/staff-management-system/commandWiring.test.js @@ -0,0 +1,192 @@ +/* + * Tests for the /staff-management command's option wiring (commands/ + * staff-management.js), separate from the underlying business logic (which is + * covered in managementLogic / issueActions / activityChecks tests). + * + * - subcommands.infraction.issue / .suspend / .void / .history and + * promotion.promote / review.submit pull the right options off the + * interaction and forward them to the (mocked) staff-management helpers + * - subcommands.panel renders the user panel ephemerally + * - activity-check.start gates on canManageChecks (permission) before starting + * - autoComplete.infraction.issue.type filters configured infraction types by + * the focused prefix, defaulting to Warning/Strike + * + * The sibling staff-management module is fully mocked so we only assert the + * command layer's plumbing. + */ + +jest.mock('../../modules/staff-management-system/staff-management', () => ({ + getConfig: (client, file) => client.configurations['staff-management-system'][file], + applyFooter: (client, embed) => embed, + checkStaffPermissions: jest.fn(() => true), + issueInfraction: jest.fn().mockResolvedValue(), + issueSuspension: jest.fn().mockResolvedValue(), + voidInfraction: jest.fn().mockResolvedValue(), + getInfractionHistory: jest.fn().mockResolvedValue(), + promoteUser: jest.fn().mockResolvedValue(), + getPromotionHistory: jest.fn().mockResolvedValue(), + generateUserPanel: jest.fn().mockResolvedValue({ + embeds: [], + components: [] + }), + startActivityCheck: jest.fn().mockResolvedValue(), + endActivityCheckProcess: jest.fn().mockResolvedValue(), + submitReview: jest.fn().mockResolvedValue(), + getReviewHistory: jest.fn().mockResolvedValue() +})); + +const mgmt = require('../../modules/staff-management-system/staff-management'); +const cmd = require('../../modules/staff-management-system/commands/staff-management'); + +function makeInteraction(opts = {}) { + const optionMap = opts.options || {}; + return { + client: { + configurations: {'staff-management-system': {}}, + models: {'staff-management-system': {}} + }, + member: { + permissions: {has: () => true}, + roles: {cache: {some: () => true}} + }, + user: {id: 'mod'}, + options: { + getUser: jest.fn((k) => optionMap[k]), + getMember: jest.fn((k) => optionMap[k]), + getString: jest.fn((k) => optionMap[k]), + getRole: jest.fn((k) => optionMap[k]), + getInteger: jest.fn((k) => optionMap[k]), + getFocused: jest.fn(() => opts.focused || '') + }, + reply: jest.fn().mockResolvedValue(), + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + respond: jest.fn().mockResolvedValue() + }; +} + +beforeEach(() => { + Object.values(mgmt).forEach(fn => typeof fn === 'function' && fn.mockClear?.()); +}); + +describe('subcommand wiring', () => { + test('panel renders the user panel ephemerally', async () => { + const user = {id: 'u1'}; + const i = makeInteraction({options: {user}}); + await cmd.subcommands.panel(i); + expect(mgmt.generateUserPanel).toHaveBeenCalledWith(i.client, user); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({flags: expect.anything()})); + }); + + test('infraction.issue forwards user/type/reason/expiry', async () => { + const member = {id: 'target'}; + const i = makeInteraction({ + options: { + user: member, + type: 'Warning', + reason: 'why', + expiry: '7d' + } + }); + await cmd.subcommands.infraction.issue(i); + expect(mgmt.issueInfraction).toHaveBeenCalledWith(i.client, i, member, 'Warning', 'why', '7d'); + }); + + test('infraction.suspend forwards user/duration/reason', async () => { + const member = {id: 'target'}; + const i = makeInteraction({ + options: { + user: member, + duration: '7d', + reason: 'why' + } + }); + await cmd.subcommands.infraction.suspend(i); + expect(mgmt.issueSuspension).toHaveBeenCalledWith(i.client, i, member, '7d', 'why'); + }); + + test('infraction.void forwards the reference', async () => { + const i = makeInteraction({options: {reference: '42'}}); + await cmd.subcommands.infraction.void(i); + expect(mgmt.voidInfraction).toHaveBeenCalledWith(i.client, i, '42'); + }); + + test('infraction.history forwards the target user', async () => { + const user = {id: 'u1'}; + const i = makeInteraction({options: {user}}); + await cmd.subcommands.infraction.history(i); + expect(mgmt.getInfractionHistory).toHaveBeenCalledWith(i.client, i, user); + }); + + test('promotion.promote forwards user/role/reason', async () => { + const member = {id: 'target'}; + const role = {id: 'role9'}; + const i = makeInteraction({ + options: { + user: member, + rank: role, + reason: 'earned' + } + }); + await cmd.subcommands.promotion.promote(i); + expect(mgmt.promoteUser).toHaveBeenCalledWith(i.client, i, member, role, 'earned'); + }); + + test('review.submit forwards user/stars/comment', async () => { + const user = {id: 'u1'}; + const i = makeInteraction({ + options: { + user, + stars: 5, + comment: 'great' + } + }); + await cmd.subcommands.review.submit(i); + expect(mgmt.submitReview).toHaveBeenCalledWith(i.client, i, user, 5, 'great'); + }); +}); + +describe('activity-check.start permission gate', () => { + // canManageChecks() is the command's own admin/role check (not the mocked + // checkStaffPermissions), so we drive it via the interaction's member. + test('starts when the member is an administrator', async () => { + const i = makeInteraction(); + i.member = { + permissions: {has: () => true}, + roles: {cache: {some: () => false}} + }; + await cmd.subcommands['activity-check'].start(i); + expect(mgmt.startActivityCheck).toHaveBeenCalledWith(i.client, i, false); + }); + + test('refuses and does not start when the member lacks permission', async () => { + const i = makeInteraction(); + i.member = { + permissions: {has: () => false}, + roles: {cache: {some: () => false}} + }; + i.client.configurations['staff-management-system'].configuration = {supervisorRoles: ['sup']}; + await cmd.subcommands['activity-check'].start(i); + expect(mgmt.startActivityCheck).not.toHaveBeenCalled(); + expect(i.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-no-perm') + })); + }); +}); + +describe('infraction type autocomplete', () => { + test('filters configured infraction types by the focused prefix', async () => { + const i = makeInteraction({focused: 'str'}); + i.client.configurations['staff-management-system'].infractions = {infractionTypes: ['Warning', 'Strike', 'Strike 2']}; + await cmd.autoComplete.infraction.issue.type(i); + const values = i.respond.mock.calls[0][0].map(c => c.value); + expect(values).toEqual(['Strike', 'Strike 2']); + }); + + test('defaults to Warning/Strike when none are configured', async () => { + const i = makeInteraction({focused: ''}); + i.client.configurations['staff-management-system'].infractions = {}; + await cmd.autoComplete.infraction.issue.type(i); + expect(i.respond.mock.calls[0][0].map(c => c.value)).toEqual(['Warning', 'Strike']); + }); +}); \ No newline at end of file diff --git a/tests/staff-management-system/dutyButtonGuards.test.js b/tests/staff-management-system/dutyButtonGuards.test.js new file mode 100644 index 00000000..5645547e --- /dev/null +++ b/tests/staff-management-system/dutyButtonGuards.test.js @@ -0,0 +1,112 @@ +/* + * Guard-clause tests for the exported duty button handlers + * (commands/duty.js -> buttonHandlers). These cover the ownership and on-duty + * state checks that short-circuit before the heavy payload builders: + * + * - handleDutyStartButton: rejects a button pressed by someone other than the + * owner, and warns when the user is already on duty + * - handleDutyBreakButton: rejects a foreign presser, and warns when the user + * is not on duty + * - handleDutyEndButton: rejects a foreign presser, and warns when not on duty + * + * customIds follow the `duty-mgmt__[_]` shape the handlers + * parse. Models are stubbed; localize comes from the deterministic stub. + */ + +const {buttonHandlers} = require('../../modules/staff-management-system/commands/duty'); + +function makeClient(profile, {shiftConfig = {}} = {}) { + return { + configurations: {'staff-management-system': {shifts: shiftConfig}}, + logger: {error: jest.fn()}, + models: { + 'staff-management-system': { + StaffProfile: { + findByPk: jest.fn().mockResolvedValue(profile), + upsert: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue() + }, + StaffShift: { + create: jest.fn().mockResolvedValue({}), + findOne: jest.fn().mockResolvedValue(null), + findAll: jest.fn().mockResolvedValue([]) + } + } + } + }; +} + +function makeInteraction(customId, userId = 'owner') { + return { + customId, + user: { + id: userId, + toString: () => `<@${userId}>` + }, + guild: {members: {fetch: jest.fn().mockResolvedValue(null)}}, + editReply: jest.fn().mockResolvedValue(), + followUp: jest.fn().mockResolvedValue() + }; +} + +describe('handleDutyStartButton guards', () => { + test('rejects a press from a non-owner', async () => { + const client = makeClient(null); + const interaction = makeInteraction('duty-mgmt_start_owner_Staff', 'someone-else'); + await buttonHandlers.handleDutyStartButton(client, interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-not-yours') + })); + expect(client.models['staff-management-system'].StaffShift.create).not.toHaveBeenCalled(); + }); + + test('warns when the user is already on duty', async () => { + const client = makeClient({onDuty: true}); + const interaction = makeInteraction('duty-mgmt_start_owner_Staff', 'owner'); + await buttonHandlers.handleDutyStartButton(client, interaction); + expect(interaction.followUp).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-alr-on') + })); + expect(client.models['staff-management-system'].StaffShift.create).not.toHaveBeenCalled(); + }); +}); + +describe('handleDutyBreakButton guards', () => { + test('rejects a press from a non-owner', async () => { + const client = makeClient({onDuty: true}); + const interaction = makeInteraction('duty-mgmt_break_owner', 'intruder'); + await buttonHandlers.handleDutyBreakButton(client, interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-not-yours') + })); + }); + + test('warns when the user is not on duty', async () => { + const client = makeClient({onDuty: false}); + const interaction = makeInteraction('duty-mgmt_break_owner', 'owner'); + await buttonHandlers.handleDutyBreakButton(client, interaction); + expect(interaction.followUp).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-not-on') + })); + }); +}); + +describe('handleDutyEndButton guards', () => { + test('rejects a press from a non-owner', async () => { + const client = makeClient({onDuty: true}); + const interaction = makeInteraction('duty-mgmt_end_owner', 'intruder'); + await buttonHandlers.handleDutyEndButton(client, interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-not-yours') + })); + }); + + test('warns when the user is not on duty', async () => { + const client = makeClient({onDuty: false}); + const interaction = makeInteraction('duty-mgmt_end_owner', 'owner'); + await buttonHandlers.handleDutyEndButton(client, interaction); + expect(interaction.followUp).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-not-on') + })); + }); +}); \ No newline at end of file diff --git a/tests/staff-management-system/dutyHelpers.test.js b/tests/staff-management-system/dutyHelpers.test.js new file mode 100644 index 00000000..c63536f6 --- /dev/null +++ b/tests/staff-management-system/dutyHelpers.test.js @@ -0,0 +1,229 @@ +/* + * Unit tests for the pure duty helpers in commands/duty.js (exported via + * module.exports._test for testability) plus the duty-type autocomplete handlers: + * + * - getLookbackDate(): All-time -> null, Weekly -> 7 days back, Monthly -> + * 1 month back, defaulting to Weekly when unset + * - canUseDutyAdmin(): delegates to checkStaffPermissions at supervisor level + * - getQuotaForMember(): disabled / no-quota cases, and picking the quota for + * the member's highest-positioned matching role + * - applyBreakElapsedToShift(): pushes the shift start forward by the elapsed + * break time, ignoring missing / future / invalid break starts + * - autoComplete.manage/leaderboard/time.type: filtering configured duty types + * by the focused prefix (leaderboard/time prepend an "All" option) + * + * The sibling staff-management helpers are real (checkStaffPermissions is pure); + * localize/getConfig come through the deterministic stubs. + */ + +const duty = require('../../modules/staff-management-system/commands/duty'); +const { + getLookbackDate, + canUseDutyAdmin, + getQuotaForMember, + applyBreakElapsedToShift +} = duty._test; + +describe('getLookbackDate', () => { + test('All-time returns null', () => { + expect(getLookbackDate({leaderboardLookback: 'All-time'})).toBeNull(); + }); + + test('Weekly returns roughly 7 days ago', () => { + const d = getLookbackDate({leaderboardLookback: 'Weekly'}); + const days = (Date.now() - d.getTime()) / 86400000; + expect(days).toBeGreaterThan(6.9); + expect(days).toBeLessThan(7.1); + }); + + test('Monthly returns about a month ago', () => { + const d = getLookbackDate({leaderboardLookback: 'Monthly'}); + expect(d.getTime()).toBeLessThan(Date.now()); + // at least ~27 days back + expect((Date.now() - d.getTime()) / 86400000).toBeGreaterThan(27); + }); + + test('defaults to Weekly when no lookback is configured', () => { + const d = getLookbackDate({}); + const days = (Date.now() - d.getTime()) / 86400000; + expect(days).toBeGreaterThan(6.9); + expect(days).toBeLessThan(7.1); + }); +}); + +describe('canUseDutyAdmin', () => { + function client(generalConfig) { + return {configurations: {'staff-management-system': {configuration: generalConfig}}}; + } + + function member(roleIds, {admin = false} = {}) { + return { + permissions: {has: (p) => admin && p === 'Administrator'}, + roles: {cache: {some: (fn) => roleIds.some(id => fn({id}))}} + }; + } + + test('grants access to supervisors and management', () => { + const c = client({ + supervisorRoles: ['sup'], + managementRoles: ['mgmt'] + }); + expect(canUseDutyAdmin(c, member(['sup']))).toBe(true); + expect(canUseDutyAdmin(c, member(['mgmt']))).toBe(true); + }); + + test('denies plain staff', () => { + const c = client({ + staffRoles: ['staff'], + supervisorRoles: ['sup'] + }); + expect(canUseDutyAdmin(c, member(['staff']))).toBe(false); + }); + + test('admins always pass', () => { + const c = client({}); + expect(canUseDutyAdmin(c, member([], {admin: true}))).toBe(true); + }); +}); + +describe('getQuotaForMember', () => { + function member(roleIds, positions = {}) { + return { + guild: {roles: {cache: {get: (id) => (positions[id] !== undefined ? {position: positions[id]} : null)}}}, + roles: {cache: {has: (id) => roleIds.includes(id)}} + }; + } + + test('returns null when quotas are disabled', () => { + expect(getQuotaForMember(member([]), { + enableQuotas: false, + quotas: {r1: '5'} + })).toBeNull(); + }); + + test('returns null when there are no configured quotas', () => { + expect(getQuotaForMember(member([]), { + enableQuotas: true, + quotas: {} + })).toBeNull(); + }); + + test('picks the quota for the highest-positioned matching role', () => { + const m = member(['r1', 'r2'], { + r1: 5, + r2: 10 + }); + const quota = getQuotaForMember(m, { + enableQuotas: true, + quotas: { + r1: '3', + r2: '8' + } + }); + expect(quota).toEqual({ + roleId: 'r2', + hours: 8 + }); + }); + + test('ignores roles the member does not hold', () => { + const m = member(['r1'], { + r1: 5, + r2: 10 + }); + const quota = getQuotaForMember(m, { + enableQuotas: true, + quotas: { + r1: '3', + r2: '8' + } + }); + expect(quota).toEqual({ + roleId: 'r1', + hours: 3 + }); + }); + + test('skips quotas with a non-numeric hour value', () => { + const m = member(['r1'], {r1: 5}); + expect(getQuotaForMember(m, { + enableQuotas: true, + quotas: {r1: 'abc'} + })).toBeNull(); + }); +}); + +describe('applyBreakElapsedToShift', () => { + test('pushes the shift start forward by the elapsed break', async () => { + const start = new Date('2024-01-01T00:00:00Z'); + const update = jest.fn().mockResolvedValue(); + const shift = { + startTime: start, + update + }; + const breakStart = new Date('2024-01-01T00:00:00Z'); + const now = new Date('2024-01-01T00:10:00Z'); // 10 minutes of break + await applyBreakElapsedToShift(shift, breakStart, now); + const newStart = update.mock.calls[0][0].startTime; + expect(newStart.getTime() - start.getTime()).toBe(10 * 60 * 1000); + }); + + test('does nothing without an active shift or break start', async () => { + const update = jest.fn(); + await applyBreakElapsedToShift(null, new Date()); + await applyBreakElapsedToShift({ + startTime: new Date(), + update + }, null); + expect(update).not.toHaveBeenCalled(); + }); + + test('ignores a future or invalid break start', async () => { + const update = jest.fn(); + const shift = { + startTime: new Date(), + update + }; + await applyBreakElapsedToShift(shift, new Date(Date.now() + 60000)); // future + await applyBreakElapsedToShift(shift, 'not-a-date'); + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe('duty type autocomplete', () => { + function interaction(value, dutyTypes) { + return { + value, + client: {configurations: {'staff-management-system': {shifts: {dutyTypes}}}}, + respond: jest.fn().mockResolvedValue() + }; + } + + test('manage.type filters configured duty types by prefix', async () => { + const i = interaction('mod', ['Moderator', 'Helper', 'Mentor']); + await duty.autoComplete.manage.type(i); + const choices = i.respond.mock.calls[0][0].map(c => c.value); + expect(choices).toEqual(['Moderator']); + }); + + test('manage.type defaults to ["Staff"] when none configured', async () => { + const i = interaction('', []); + await duty.autoComplete.manage.type(i); + expect(i.respond.mock.calls[0][0].map(c => c.value)).toEqual(['Staff']); + }); + + test('leaderboard.type prepends an "All" option', async () => { + const i = interaction('a', ['Admin', 'Helper']); + await duty.autoComplete.leaderboard.type(i); + const choices = i.respond.mock.calls[0][0].map(c => c.value); + // "All" and "Admin" both start with "a" (case-insensitive) + expect(choices).toEqual(expect.arrayContaining(['All', 'Admin'])); + expect(choices).not.toContain('Helper'); + }); + + test('time.type also offers the "All" option', async () => { + const i = interaction('', ['Staff']); + await duty.autoComplete.time.type(i); + expect(i.respond.mock.calls[0][0].map(c => c.value)).toEqual(['All', 'Staff']); + }); +}); \ No newline at end of file diff --git a/tests/staff-management-system/helpers.test.js b/tests/staff-management-system/helpers.test.js new file mode 100644 index 00000000..656b46d2 --- /dev/null +++ b/tests/staff-management-system/helpers.test.js @@ -0,0 +1,147 @@ +/* + * Pure-logic tests for the staff-management-system helper functions exported + * from staff-management.js: + * - checkStaffPermissions(): admin shortcut, per-level role gating + * (staff/supervisor/management), and the no-member / no-roles defaults + * - parseDurationToDays(): parses "5d"/"2w"/"3m" duration strings to days, + * defaulting the unit to days, and rejecting malformed input + * - getSafeChannelId(): coerces array/string channel config into a single id + * - formatDuration(): humanises a second count into h/m/s parts + * - getIsoWeekNumber(): ISO-8601 week numbers for known dates + * + * localize is auto-stubbed by jest.config moduleNameMapper, so the formatted + * strings carry deterministic "namespace.key" tokens we can assert against. + */ + +const { + checkStaffPermissions, + parseDurationToDays, + getSafeChannelId, + formatDuration, + getIsoWeekNumber +} = require('../../modules/staff-management-system/staff-management'); + +function member(roleIds, {admin = false} = {}) { + return { + permissions: {has: (p) => admin && p === 'Administrator'}, + roles: {cache: {some: (fn) => roleIds.some(id => fn({id}))}} + }; +} + +const config = { + staffRoles: ['staff'], + supervisorRoles: ['sup'], + managementRoles: ['mgmt'] +}; + +describe('checkStaffPermissions', () => { + test('returns false when no member is supplied', () => { + expect(checkStaffPermissions(null, config, 'staff')).toBe(false); + }); + + test('administrators always pass regardless of level', () => { + expect(checkStaffPermissions(member([], {admin: true}), config, 'management')).toBe(true); + }); + + test('staff level accepts staff, supervisor and management roles', () => { + expect(checkStaffPermissions(member(['staff']), config, 'staff')).toBe(true); + expect(checkStaffPermissions(member(['sup']), config, 'staff')).toBe(true); + expect(checkStaffPermissions(member(['mgmt']), config, 'staff')).toBe(true); + }); + + test('supervisor level rejects plain staff but accepts supervisor/management', () => { + expect(checkStaffPermissions(member(['staff']), config, 'supervisor')).toBe(false); + expect(checkStaffPermissions(member(['sup']), config, 'supervisor')).toBe(true); + expect(checkStaffPermissions(member(['mgmt']), config, 'supervisor')).toBe(true); + }); + + test('management level only accepts management roles', () => { + expect(checkStaffPermissions(member(['sup']), config, 'management')).toBe(false); + expect(checkStaffPermissions(member(['mgmt']), config, 'management')).toBe(true); + }); + + test('a member with none of the configured roles is rejected', () => { + expect(checkStaffPermissions(member(['other']), config, 'staff')).toBe(false); + }); + + test('defaults to the staff level for an unknown level', () => { + expect(checkStaffPermissions(member(['staff']), config, 'bogus')).toBe(true); + }); +}); + +describe('parseDurationToDays', () => { + test('returns null for empty/invalid input', () => { + expect(parseDurationToDays(null)).toBeNull(); + expect(parseDurationToDays('')).toBeNull(); + expect(parseDurationToDays('abc')).toBeNull(); + expect(parseDurationToDays('5x')).toBeNull(); + }); + + test('defaults a bare number to days', () => { + expect(parseDurationToDays('5')).toBe(5); + expect(parseDurationToDays('5d')).toBe(5); + }); + + test('converts weeks and months', () => { + expect(parseDurationToDays('2w')).toBe(14); + expect(parseDurationToDays('3m')).toBe(90); + }); + + test('is case-insensitive on the unit', () => { + expect(parseDurationToDays('1W')).toBe(7); + expect(parseDurationToDays('1M')).toBe(30); + }); +}); + +describe('getSafeChannelId', () => { + test('returns the first element of a non-empty array', () => { + expect(getSafeChannelId(['a', 'b'])).toBe('a'); + }); + + test('returns a plain string unchanged', () => { + expect(getSafeChannelId('chan')).toBe('chan'); + }); + + test('returns null for empty arrays and other types', () => { + expect(getSafeChannelId([])).toBeNull(); + expect(getSafeChannelId(null)).toBeNull(); + expect(getSafeChannelId(undefined)).toBeNull(); + expect(getSafeChannelId(42)).toBeNull(); + }); +}); + +describe('formatDuration', () => { + test('returns the zero token for non-positive durations', () => { + expect(formatDuration(0)).toContain('time-zero'); + expect(formatDuration(-5)).toContain('time-zero'); + }); + + test('includes hours, minutes and seconds parts as needed', () => { + const out = formatDuration(3661); // 1h 1m 1s + expect(out).toContain('1 staff-management-system.time-hour'); + expect(out).toContain('1 staff-management-system.time-min'); + expect(out).toContain('1 staff-management-system.time-sec'); + }); + + test('omits zero-valued parts', () => { + const out = formatDuration(120); // exactly 2 minutes + expect(out).toContain('2 staff-management-system.time-mins'); + expect(out).not.toContain('time-hour'); + expect(out).not.toContain('time-sec'); + }); +}); + +describe('getIsoWeekNumber', () => { + test('Jan 4th is always in ISO week 1', () => { + expect(getIsoWeekNumber(new Date(Date.UTC(2024, 0, 4)))).toBe(1); + }); + + test('computes mid-year week numbers', () => { + // 2024-07-01 is a Monday in ISO week 27. + expect(getIsoWeekNumber(new Date(Date.UTC(2024, 6, 1)))).toBe(27); + }); + + test('Dec 31 2020 belongs to ISO week 53', () => { + expect(getIsoWeekNumber(new Date(Date.UTC(2020, 11, 31)))).toBe(53); + }); +}); diff --git a/tests/staff-management-system/interactionCreate.test.js b/tests/staff-management-system/interactionCreate.test.js new file mode 100644 index 00000000..53770f28 --- /dev/null +++ b/tests/staff-management-system/interactionCreate.test.js @@ -0,0 +1,216 @@ +/* + * Behavior tests for the staff-management-system interaction router + * (events/interactionCreate.js). + * + * The router gates and dispatches button/modal/select interactions. These tests + * exercise the branches that have decision logic rather than heavy embed + * rendering: + * - the customId guard (ignores foreign / unprefixed interactions, and + * interactions before the bot is ready) + * - LOA approve/deny supervisor permission gating (non-supervisors rejected) + * - the "request already handled" guard on a non-PENDING request + * - the activity-check (ac-respond) flow: ended check, role requirement, + * duplicate-response short-circuit, and a successful log + * + * The staff-management helper module and discord.js builders are real (the + * discordjs-fix shim provides v13 names); models and the interaction object are + * mocked. + */ + +const handler = require('../../modules/staff-management-system/events/interactionCreate'); + +function baseClient(extra = {}) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn() + }, + configurations: { + 'staff-management-system': { + configuration: { + staffRoles: ['staff'], + supervisorRoles: ['sup'], + managementRoles: ['mgmt'] + }, + status: { + loaRole: 'loa-role', + raRole: 'ra-role' + } + } + }, + models: {'staff-management-system': {}}, + ...extra + }; +} + +function baseInteraction(customId, overrides = {}) { + return { + customId, + guild: {id: 'g1'}, + user: { + id: 'u1', + tag: 'U#1' + }, + member: { + permissions: {has: () => false}, + roles: {cache: {some: () => false}} + }, + replied: false, + deferred: false, + isStringSelectMenu: () => false, + isModalSubmit: () => false, + reply: jest.fn().mockResolvedValue(), + deferUpdate: jest.fn().mockResolvedValue(), + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + followUp: jest.fn().mockResolvedValue(), + ...overrides + }; +} + +describe('router guards', () => { + test('ignores interactions before the bot is ready', async () => { + const client = baseClient({botReadyAt: null}); + const interaction = baseInteraction('staff-mgmt_approve_1'); + await handler.run(client, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + }); + + test('ignores interactions from another guild', async () => { + const client = baseClient(); + const interaction = baseInteraction('staff-mgmt_approve_1', {guild: {id: 'other'}}); + await handler.run(client, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + }); + + test('ignores customIds without the staff-mgmt / duty-mgmt prefix', async () => { + const client = baseClient(); + const interaction = baseInteraction('some-other-button'); + await handler.run(client, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + expect(interaction.deferUpdate).not.toHaveBeenCalled(); + }); +}); + +describe('LOA approve/deny permission gating', () => { + test('rejects a non-supervisor trying to approve', async () => { + const client = baseClient(); + client.models['staff-management-system'].LoaRequest = {findByPk: jest.fn()}; + client.models['staff-management-system'].StaffProfile = {upsert: jest.fn()}; + const interaction = baseInteraction('staff-mgmt_approve_5'); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: 'staff-management-system.err-gen-no-perm'}) + ); + // never looked up the request because permission failed first + expect(client.models['staff-management-system'].LoaRequest.findByPk).not.toHaveBeenCalled(); + }); + + test('tells the supervisor when the request is already handled', async () => { + const client = baseClient(); + client.models['staff-management-system'].LoaRequest = { + findByPk: jest.fn().mockResolvedValue({status: 'APPROVED'}) + }; + client.models['staff-management-system'].StaffProfile = {upsert: jest.fn()}; + const interaction = baseInteraction('staff-mgmt_approve_5', { + member: { + permissions: {has: () => false}, + roles: {cache: {some: (fn) => fn({id: 'sup'})}} + } + }); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: 'staff-management-system.err-req-hndl(status=APPROVED)'}) + ); + }); +}); + +describe('activity-check ac-respond', () => { + function acClient({ + activeCheck, + existingResponse = null, + targetRoles = '[]' + } = {}) { + const client = baseClient(); + client.models['staff-management-system'].ActivityCheck = { + findOne: jest.fn().mockResolvedValue(activeCheck ? { + id: 7, + targetRoles, ...activeCheck + } : null) + }; + client.models['staff-management-system'].ActivityCheckResponse = { + findOne: jest.fn().mockResolvedValue(existingResponse), + create: jest.fn().mockResolvedValue() + }; + return client; + } + + test('rejects when no active check matches the message', async () => { + const client = acClient({activeCheck: null}); + const interaction = baseInteraction('staff-mgmt_ac-respond', {message: {id: 'm1'}}); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: 'staff-management-system.err-ac-alr-end'}) + ); + }); + + test('rejects a member who lacks a required target role', async () => { + const client = acClient({ + activeCheck: {}, + targetRoles: JSON.stringify(['needed']) + }); + const interaction = baseInteraction('staff-mgmt_ac-respond', { + message: {id: 'm1'}, + member: { + permissions: {has: () => false}, + roles: {cache: {some: () => false}} + } + }); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: 'staff-management-system.err-ac-not-req'}) + ); + expect(client.models['staff-management-system'].ActivityCheckResponse.create).not.toHaveBeenCalled(); + }); + + test('short-circuits when the member already responded', async () => { + const client = acClient({ + activeCheck: {}, + existingResponse: {id: 99} + }); + const interaction = baseInteraction('staff-mgmt_ac-respond', {message: {id: 'm1'}}); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: 'staff-management-system.info-ac-alr-conf'}) + ); + expect(client.models['staff-management-system'].ActivityCheckResponse.create).not.toHaveBeenCalled(); + }); + + test('logs a response and confirms when eligible and not yet responded', async () => { + const client = acClient({activeCheck: {}}); + const interaction = baseInteraction('staff-mgmt_ac-respond', {message: {id: 'm1'}}); + await handler.run(client, interaction); + expect(client.models['staff-management-system'].ActivityCheckResponse.create).toHaveBeenCalledWith({ + activityCheckId: 7, + userId: 'u1' + }); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: 'staff-management-system.succ-ac-log'}) + ); + }); + + test('treats a unique-constraint race as an already-confirmed response', async () => { + const client = acClient({activeCheck: {}}); + client.models['staff-management-system'].ActivityCheckResponse.create = + jest.fn().mockRejectedValue(Object.assign(new Error('dup'), {name: 'SequelizeUniqueConstraintError'})); + const interaction = baseInteraction('staff-mgmt_ac-respond', {message: {id: 'm1'}}); + await handler.run(client, interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({content: 'staff-management-system.info-ac-alr-conf'}) + ); + }); +}); \ No newline at end of file diff --git a/tests/staff-management-system/issueActions.test.js b/tests/staff-management-system/issueActions.test.js new file mode 100644 index 00000000..7e21f0fc --- /dev/null +++ b/tests/staff-management-system/issueActions.test.js @@ -0,0 +1,313 @@ +/* + * Behavior tests for the moderation "issue" actions in staff-management.js: + * + * - issueInfraction(): feature gate, self-target guard, permission gate, the + * "use the suspension command instead" guard, invalid-duration rejection, and + * the happy path that persists an Infraction + * - issueSuspension(): both feature gates (infractions + suspensions), + * self-target / permission guards, invalid duration, and the happy path that + * upserts a suspended StaffProfile, adds the suspension role and creates the + * Infraction record + * - promoteUser(): feature gate, self-promote guard, the role-hierarchy guard + * when autoAddRole is on, and the happy path that adds the role + persists a + * Promotion + * + * embedTypeV2 / dateToDiscordTimestamp / safeSetFooter are mocked; the channel + * log + DM steps are exercised lightly (no channel configured) to keep the focus + * on the decision logic and persistence. + */ + +jest.mock('../../src/functions/helpers', () => ({ + embedTypeV2: jest.fn().mockResolvedValue({ + content: '', + embeds: [] + }), + safeSetFooter: jest.fn((embed) => embed), + dateToDiscordTimestamp: jest.fn(() => '') +})); + +const mgmt = require('../../modules/staff-management-system/staff-management'); + +function modelStub(methods = {}) { + return { + create: jest.fn().mockResolvedValue({ + caseId: 100, + update: jest.fn().mockResolvedValue() + }), + upsert: jest.fn().mockResolvedValue(), + findOne: jest.fn().mockResolvedValue(null), + ...methods + }; +} + +function makeClient(models = {}, configs = {}) { + return { + guildID: 'g1', + strings: {footer: 'f'}, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn() + }, + models: { + 'staff-management-system': { + Infraction: modelStub(), + StaffProfile: modelStub(), + Promotion: modelStub(), + ...models + } + }, + configurations: {'staff-management-system': configs} + }; +} + +function targetMember(id = 'target') { + return { + id, + user: { + id, + tag: 'T#1', + username: 'T', + toString: () => `<@${id}>`, + displayAvatarURL: () => 'https://cdn.example/a.png', + send: jest.fn().mockResolvedValue() + }, + roles: { + cache: {filter: () => ({map: () => []})}, + remove: jest.fn().mockResolvedValue(), + add: jest.fn().mockResolvedValue() + } + }; +} + +function makeInteraction(overrides = {}) { + return { + user: { + id: 'mod', + username: 'Mod', + toString: () => '<@mod>', + displayAvatarURL: () => 'https://cdn.example/m.png' + }, + member: { + permissions: {has: () => true}, + roles: {cache: {some: () => true}} + }, + guild: { + channels: {fetch: jest.fn().mockResolvedValue(null)}, + roles: {cache: {get: () => null}} + }, + options: {getChannel: () => null}, + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + ...overrides + }; +} + +describe('issueInfraction', () => { + test('is gated behind enableInfractions', async () => { + const client = makeClient({}, {infractions: {enableInfractions: false}}); + const interaction = makeInteraction(); + await mgmt.issueInfraction(client, interaction, targetMember(), 'Warning', 'reason', null); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-feat-disabled')})); + }); + + test('refuses self-infractions', async () => { + const client = makeClient({}, {infractions: {enableInfractions: true}}); + const interaction = makeInteraction({ + user: { + id: 'self', + username: 'S', + toString: () => '<@self>', + displayAvatarURL: () => 'x' + } + }); + await mgmt.issueInfraction(client, interaction, targetMember('self'), 'Warning', 'r', null); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-self-infract')})); + }); + + test('rejects insufficient permissions', async () => { + const client = makeClient({}, { + infractions: { + enableInfractions: true, + staffRoles: ['staff'] + } + }); + const interaction = makeInteraction({ + member: { + permissions: {has: () => false}, + roles: {cache: {some: () => false}} + } + }); + await mgmt.issueInfraction(client, interaction, targetMember(), 'Warning', 'r', null); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-gen-no-perm')})); + }); + + test('redirects suspensions to the dedicated command', async () => { + const client = makeClient({}, {infractions: {enableInfractions: true}}); + const interaction = makeInteraction(); + await mgmt.issueInfraction(client, interaction, targetMember(), 'Suspension', 'r', null); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-use-susp')})); + }); + + test('rejects an invalid expiry duration', async () => { + const client = makeClient({}, {infractions: {enableInfractions: true}}); + const interaction = makeInteraction(); + await mgmt.issueInfraction(client, interaction, targetMember(), 'Warning', 'r', 'garbage'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-inv-dur')})); + }); + + test('persists the infraction on the happy path', async () => { + const create = jest.fn().mockResolvedValue({ + caseId: 42, + update: jest.fn().mockResolvedValue() + }); + const client = makeClient({Infraction: modelStub({create})}, {infractions: {enableInfractions: true}}); + const interaction = makeInteraction(); + await mgmt.issueInfraction(client, interaction, targetMember(), 'Warning', 'broke a rule', '7d'); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'target', + issuerId: 'mod', + type: 'Warning', + reason: 'broke a rule', + active: true + })); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('succ-infract')})); + }); +}); + +describe('issueSuspension', () => { + test('requires both enableInfractions and enableSuspensions', async () => { + const client1 = makeClient({}, { + infractions: { + enableInfractions: false, + enableSuspensions: true + } + }); + const i1 = makeInteraction(); + await mgmt.issueSuspension(client1, i1, targetMember(), '7d', 'r'); + expect(i1.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-feat-disabled')})); + + const client2 = makeClient({}, { + infractions: { + enableInfractions: true, + enableSuspensions: false + } + }); + const i2 = makeInteraction(); + await mgmt.issueSuspension(client2, i2, targetMember(), '7d', 'r'); + expect(i2.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-feat-disabled')})); + }); + + test('rejects an invalid duration', async () => { + const client = makeClient({}, { + infractions: { + enableInfractions: true, + enableSuspensions: true + } + }); + const interaction = makeInteraction(); + await mgmt.issueSuspension(client, interaction, targetMember(), 'nonsense', 'r'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-inv-dur')})); + }); + + test('suspends: upserts the profile, adds the role and creates the infraction', async () => { + const upsert = jest.fn().mockResolvedValue(); + const create = jest.fn().mockResolvedValue({ + caseId: 50, + update: jest.fn().mockResolvedValue() + }); + const client = makeClient( + { + StaffProfile: modelStub({upsert}), + Infraction: modelStub({create}) + }, + { + infractions: { + enableInfractions: true, + enableSuspensions: true, + suspensionRole: 'susp-role' + } + } + ); + const target = targetMember(); + const interaction = makeInteraction(); + await mgmt.issueSuspension(client, interaction, target, '7d', 'bad behaviour'); + expect(upsert).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'target', + isSuspended: true + })); + expect(target.roles.add).toHaveBeenCalledWith('susp-role'); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + type: 'Suspension', + durationDays: 7 + })); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('succ-susp')})); + }); +}); + +describe('promoteUser', () => { + function newRole(position = 1) { + return { + id: 'role9', + name: 'Senior', + position, + toString: () => '<@&role9>' + }; + } + + test('is gated behind enablePromotions', async () => { + const client = makeClient({}, {promotions: {enablePromotions: false}}); + const interaction = makeInteraction(); + await mgmt.promoteUser(client, interaction, targetMember(), newRole(), 'great'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-feat-disabled')})); + }); + + test('refuses self-promotion', async () => { + const client = makeClient({}, {promotions: {enablePromotions: true}}); + const interaction = makeInteraction({ + user: { + id: 'self', + username: 'S', + toString: () => '<@self>', + displayAvatarURL: () => 'x' + } + }); + await mgmt.promoteUser(client, interaction, targetMember('self'), newRole(), 'great'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-self-promo')})); + }); + + test('blocks when the bot role is not high enough to grant the role', async () => { + const client = makeClient({}, { + promotions: { + enablePromotions: true, + autoAddRole: true + } + }); + const interaction = makeInteraction(); + interaction.guild.members = {me: {roles: {highest: {position: 1}}}}; + await mgmt.promoteUser(client, interaction, targetMember(), newRole(5), 'great'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('err-role-hier')})); + }); + + test('adds the role and persists a promotion on the happy path', async () => { + const create = jest.fn().mockResolvedValue({update: jest.fn().mockResolvedValue()}); + const client = makeClient({Promotion: modelStub({create})}, { + promotions: { + enablePromotions: true, + autoAddRole: true + } + }); + const interaction = makeInteraction(); + interaction.guild.members = {me: {roles: {highest: {position: 10}}}}; + const target = targetMember(); + await mgmt.promoteUser(client, interaction, target, newRole(5), 'earned it'); + expect(target.roles.add).toHaveBeenCalled(); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'target', + issuerId: 'mod', + newRole: 'role9', + reason: 'earned it' + })); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('succ-promo')})); + }); +}); \ No newline at end of file diff --git a/tests/staff-management-system/managementLogic.test.js b/tests/staff-management-system/managementLogic.test.js new file mode 100644 index 00000000..e630757f --- /dev/null +++ b/tests/staff-management-system/managementLogic.test.js @@ -0,0 +1,577 @@ +/* + * Behavior tests for the data-driven logic in staff-management.js that the + * existing helpers.test.js / interactionCreate.test.js do not cover: + * + * - generateInfractionHistoryResponse(): empty "clean record" path, the + * pagination math (5 per page) and the active/voided status icons / jump links + * - generatePromotionHistoryResponse(): empty path + populated rows + * - generateReviewHistoryResponse(): feature-disabled gate, average-stars math + * - generatePanelInfractions/Promotions/Reviews/Status: the page-1 (3 items) + * vs page-2 (5 items) limit/offset split and totalPages computation + * - generatePanelSubpage(): the type -> generator dispatch table + * - executeDataDeletion(): which models get destroyed / which profile fields + * get reset for each deletion scope (incl. del_all) + * - submitReview(): feature gate, not-a-member, self-rate gate, staff-only gate, + * and the happy path that persists a review + * - voidInfraction(): permission gate, missing/inactive case, suspension role + * restoration, and the generic void path + * + * discord.js builders are real (via the discordjs-fix shim); the helpers that hit + * Discord formatting (embedTypeV2 / dateToDiscordTimestamp / safeSetFooter) are + * mocked so we assert on decision logic and model interactions, not embed bytes. + */ + +jest.mock('../../src/functions/helpers', () => ({ + embedTypeV2: jest.fn().mockResolvedValue({content: 'rendered'}), + safeSetFooter: jest.fn((embed) => embed), + dateToDiscordTimestamp: jest.fn(() => ''), + disableModule: jest.fn(), + formatDiscordUserName: (u) => (u && u.tag) || 'user' +})); + +const mgmt = require('../../modules/staff-management-system/staff-management'); + +function makeUser(overrides = {}) { + return { + id: 'u1', + username: 'Target', + tag: 'Target#1', + toString: () => '<@u1>', + displayAvatarURL: () => 'https://cdn.example/avatar.png', + ...overrides + }; +} + +function modelStub(methods = {}) { + return { + findAndCountAll: jest.fn().mockResolvedValue({ + count: 0, + rows: [] + }), + findAll: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(0), + create: jest.fn().mockResolvedValue({update: jest.fn().mockResolvedValue()}), + destroy: jest.fn().mockResolvedValue(), + findByPk: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(), + ...methods + }; +} + +function makeClient(models = {}, configurations = {}) { + return { + guildID: 'g1', + strings: {footer: 'f'}, + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn() + }, + guilds: {cache: {get: () => null}}, + models: { + 'staff-management-system': { + Infraction: modelStub(), + Promotion: modelStub(), + StaffReview: modelStub(), + LoaRequest: modelStub(), + StaffProfile: modelStub(), + ActivityCheck: modelStub(), + ActivityCheckResponse: modelStub(), + StaffShift: modelStub(), + ...models + } + }, + configurations: {'staff-management-system': configurations} + }; +} + +describe('generateInfractionHistoryResponse', () => { + test('returns an ephemeral "clean record" message when there are no infractions', async () => { + const client = makeClient(); + const res = await mgmt.generateInfractionHistoryResponse(client, makeUser(), 1); + expect(res.content).toContain('info-clean-rec'); + expect(res.embeds).toBeUndefined(); + }); + + test('renders rows with pagination metadata when infractions exist', async () => { + const rows = [ + { + caseId: 10, + type: 'Warning', + active: true, + reason: 'spam', + createdAt: new Date(), + expiresAt: null, + issuerId: 'mod', + messageUrl: 'https://x' + }, + { + caseId: 11, + type: 'Mute', + active: false, + reason: 'rude', + createdAt: new Date(), + expiresAt: new Date(), + issuerId: 'mod', + messageUrl: null + } + ]; + const client = makeClient({ + Infraction: modelStub({ + findAndCountAll: jest.fn().mockResolvedValue({ + count: 7, + rows + }) + }) + }); + const res = await mgmt.generateInfractionHistoryResponse(client, makeUser(), 1); + expect(res.embeds).toHaveLength(1); + expect(res.components).toHaveLength(1); + // 7 infractions / 5 per page => 2 pages + const desc = res.embeds[0].description; + expect(desc).toContain('#10'); + expect(desc).toContain('#11'); + // active uses 🔴, voided uses the voided icon token + expect(desc).toContain('🔴'); + expect(desc).toContain('icon-voided'); + // jump link only for the one with a messageUrl + expect(desc).toContain('[Jump](https://x)'); + }); + + test('paginates with limit 5 and the correct offset for page 2', async () => { + const findAndCountAll = jest.fn().mockResolvedValue({ + count: 7, + rows: [] + }); + const client = makeClient({Infraction: modelStub({findAndCountAll})}); + await mgmt.generateInfractionHistoryResponse(client, makeUser(), 2); + expect(findAndCountAll).toHaveBeenCalledWith(expect.objectContaining({ + limit: 5, + offset: 5 + })); + }); +}); + +describe('generatePromotionHistoryResponse', () => { + test('returns the "no promotions" info message when empty', async () => { + const client = makeClient(); + const res = await mgmt.generatePromotionHistoryResponse(client, makeUser(), 1); + expect(res.content).toContain('info-no-promo'); + }); + + test('renders promotion rows and includes the role mention', async () => { + const rows = [{ + newRole: 'role9', + issuerId: 'mod', + reason: 'great work', + createdAt: new Date(), + messageUrl: null + }]; + const client = makeClient({ + Promotion: modelStub({ + findAndCountAll: jest.fn().mockResolvedValue({ + count: 1, + rows + }) + }) + }); + const res = await mgmt.generatePromotionHistoryResponse(client, makeUser(), 1); + expect(res.embeds[0].description).toContain('<@&role9>'); + }); +}); + +describe('generateReviewHistoryResponse', () => { + test('is gated behind enableReviews', async () => { + const client = makeClient({}, {reviews: {enableReviews: false}}); + const res = await mgmt.generateReviewHistoryResponse(client, makeUser(), 1); + expect(res.content).toContain('err-feat-disabled'); + }); + + test('computes the average star rating', async () => { + const client = makeClient({ + StaffReview: modelStub({ + findAndCountAll: jest.fn().mockResolvedValue({ + count: 2, + rows: [ + { + stars: 5, + authorId: 'a', + comment: 'good', + messageUrl: null + }, + { + stars: 3, + authorId: 'b', + comment: 'ok', + messageUrl: null + } + ] + }), + findAll: jest.fn().mockResolvedValue([{stars: 5}, {stars: 3}]) + }) + }, {reviews: {enableReviews: true}}); + const res = await mgmt.generateReviewHistoryResponse(client, makeUser(), 1); + // (5 + 3) / 2 = 4.0 -> appears in the description placeholder args + expect(res.embeds[0].description).toContain('avg=4.0'); + }); +}); + +describe('panel page limit/offset split (3 then 5)', () => { + test('infractions page 1 fetches 3 items at offset 0', async () => { + const findAll = jest.fn().mockResolvedValue([]); + const client = makeClient({Infraction: modelStub({findAll})}); + await mgmt.generatePanelInfractions(client, makeUser(), 1); + // last findAll call is the paginated one + const opts = findAll.mock.calls.at(-1)[0]; + expect(opts.limit).toBe(3); + expect(opts.offset).toBe(0); + }); + + test('infractions page 2 fetches 5 items at offset 3', async () => { + const findAll = jest.fn().mockResolvedValue([]); + const client = makeClient({Infraction: modelStub({findAll})}); + await mgmt.generatePanelInfractions(client, makeUser(), 2); + const opts = findAll.mock.calls.at(-1)[0]; + expect(opts.limit).toBe(5); + expect(opts.offset).toBe(3); + }); + + test('promotions page 3 fetches 5 items at offset 8', async () => { + const findAll = jest.fn().mockResolvedValue([]); + const client = makeClient({ + Promotion: modelStub({ + count: jest.fn().mockResolvedValue(0), + findAll + }) + }); + await mgmt.generatePanelPromotions(client, makeUser(), 3); + const opts = findAll.mock.calls.at(-1)[0]; + expect(opts.limit).toBe(5); + expect(opts.offset).toBe(8); // 3 + (3-2)*5 + }); + + test('reviews panel computes the average and renders stars', async () => { + const all = [{ + stars: 4, + authorId: 'a', + comment: 'x' + }, { + stars: 2, + authorId: 'b', + comment: 'y' + }]; + const client = makeClient({StaffReview: modelStub({findAll: jest.fn().mockResolvedValue(all)})}); + const res = await mgmt.generatePanelReviews(client, makeUser(), 1); + // avg (4+2)/2 = 3.0 fed to the description token + expect(res.embeds[0].description).toContain('avg=3.0'); + }); + + test('status panel surfaces the active APPROVED status', async () => { + const future = new Date(Date.now() + 86400000); + const statuses = [{ + status: 'APPROVED', + type: 'LOA', + endDate: future, + startDate: new Date(), + reason: 'trip' + }]; + const client = makeClient({LoaRequest: modelStub({findAll: jest.fn().mockResolvedValue(statuses)})}); + const res = await mgmt.generatePanelStatus(client, makeUser(), 1); + expect(res.embeds[0].description).toContain('LOA'); + }); +}); + +describe('generatePanelSubpage dispatch', () => { + test('routes each type to its generator and returns null for unknown types', async () => { + const client = makeClient(); + expect(await mgmt.generatePanelSubpage(client, makeUser(), 'infractions', 1)).toBeTruthy(); + expect(await mgmt.generatePanelSubpage(client, makeUser(), 'promotions', 1)).toBeTruthy(); + expect(await mgmt.generatePanelSubpage(client, makeUser(), 'reviews', 1)).toBeTruthy(); + expect(await mgmt.generatePanelSubpage(client, makeUser(), 'status', 1)).toBeTruthy(); + expect(await mgmt.generatePanelSubpage(client, makeUser(), 'bogus', 1)).toBeNull(); + }); +}); + +describe('executeDataDeletion', () => { + test('del_infractions only destroys infractions', async () => { + const client = makeClient(); + const models = client.models['staff-management-system']; + await mgmt.executeDataDeletion(client, 'u1', 'del_infractions'); + expect(models.Infraction.destroy).toHaveBeenCalledWith({where: {userId: 'u1'}}); + expect(models.Promotion.destroy).not.toHaveBeenCalled(); + expect(models.StaffReview.destroy).not.toHaveBeenCalled(); + }); + + test('del_reviews destroys reviews keyed by targetId', async () => { + const client = makeClient(); + await mgmt.executeDataDeletion(client, 'u1', 'del_reviews'); + expect(client.models['staff-management-system'].StaffReview.destroy) + .toHaveBeenCalledWith({where: {targetId: 'u1'}}); + }); + + test('del_shifts resets the duty profile fields', async () => { + const profile = {update: jest.fn().mockResolvedValue()}; + const client = makeClient({StaffProfile: modelStub({findByPk: jest.fn().mockResolvedValue(profile)})}); + await mgmt.executeDataDeletion(client, 'u1', 'del_shifts'); + expect(profile.update).toHaveBeenCalledWith(expect.objectContaining({ + onDuty: false, + onBreak: false, + breakStartTime: null, + lastClockIn: null + })); + }); + + test('del_all destroys every model and wipes the whole profile', async () => { + const profile = {update: jest.fn().mockResolvedValue()}; + const client = makeClient({StaffProfile: modelStub({findByPk: jest.fn().mockResolvedValue(profile)})}); + const models = client.models['staff-management-system']; + await mgmt.executeDataDeletion(client, 'u1', 'del_all'); + expect(models.Infraction.destroy).toHaveBeenCalled(); + expect(models.Promotion.destroy).toHaveBeenCalled(); + expect(models.StaffReview.destroy).toHaveBeenCalled(); + expect(models.ActivityCheckResponse.destroy).toHaveBeenCalled(); + expect(profile.update).toHaveBeenCalledWith(expect.objectContaining({ + isSuspended: false, + customNickname: null, + customIntro: null, + activityStatus: null + })); + }); + + test('skips the profile update when no profile exists', async () => { + const client = makeClient({StaffProfile: modelStub({findByPk: jest.fn().mockResolvedValue(null)})}); + await expect(mgmt.executeDataDeletion(client, 'u1', 'del_shifts')).resolves.toBeUndefined(); + }); +}); + +describe('submitReview', () => { + function reviewInteraction(overrides = {}) { + return { + user: { + id: 'author', + toString: () => '<@author>', + displayAvatarURL: () => 'a' + }, + guild: { + members: { + fetch: jest.fn().mockResolvedValue({roles: {cache: {some: () => true}}}), + channels: {cache: {get: () => null}} + } + }, + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + ...overrides + }; + } + + test('is gated behind enableReviews', async () => { + const client = makeClient({}, {reviews: {enableReviews: false}}); + const interaction = reviewInteraction(); + await mgmt.submitReview(client, interaction, makeUser(), 5, 'nice'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-feat-disabled') + })); + }); + + test('rejects reviewing someone who is not a guild member', async () => { + const client = makeClient({}, {reviews: {enableReviews: true}}); + const interaction = reviewInteraction(); + interaction.guild.members.fetch = jest.fn().mockResolvedValue(null); + await mgmt.submitReview(client, interaction, makeUser(), 5, 'nice'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-not-mem') + })); + }); + + test('rejects self-reviews unless allowSelfRating is set', async () => { + const client = makeClient({}, { + reviews: { + enableReviews: true, + allowSelfRating: false, + onlyAllowStaffReview: false + } + }); + const interaction = reviewInteraction(); + await mgmt.submitReview(client, interaction, makeUser({id: 'author'}), 5, 'nice'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-self-rate') + })); + }); + + test('rejects reviewing a non-staff member when staff-only is enabled', async () => { + const client = makeClient({}, { + reviews: { + enableReviews: true, + onlyAllowStaffReview: true + }, + configuration: {staffRoles: ['staff']} + }); + const interaction = reviewInteraction(); + interaction.guild.members.fetch = jest.fn().mockResolvedValue({roles: {cache: {some: () => false}}}); + await mgmt.submitReview(client, interaction, makeUser(), 5, 'nice'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-staff-rate') + })); + }); + + test('persists the review on the happy path', async () => { + const create = jest.fn().mockResolvedValue({update: jest.fn().mockResolvedValue()}); + const client = makeClient({StaffReview: modelStub({create})}, + { + reviews: { + enableReviews: true, + allowSelfRating: true, + onlyAllowStaffReview: false + } + }); + const interaction = reviewInteraction(); + await mgmt.submitReview(client, interaction, makeUser(), 4, 'solid'); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + targetId: 'u1', + authorId: 'author', + stars: 4, + comment: 'solid' + })); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('succ-review') + })); + }); +}); + +describe('voidInfraction', () => { + function voidInteraction() { + return { + user: {id: 'mod'}, + member: { + permissions: {has: () => true}, + roles: {cache: {some: () => true}} + }, + guild: { + members: {fetch: jest.fn().mockResolvedValue(null)}, + channels: {fetch: jest.fn()} + }, + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue() + }; + } + + test('is gated behind enableInfractions', async () => { + const client = makeClient({}, {infractions: {enableInfractions: false}}); + const interaction = voidInteraction(); + await mgmt.voidInfraction(client, interaction, '5'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-feat-disabled') + })); + }); + + test('rejects non-supervisors', async () => { + const client = makeClient({}, { + infractions: {enableInfractions: true}, + configuration: {supervisorRoles: ['sup']} + }); + const interaction = voidInteraction(); + interaction.member = { + permissions: {has: () => false}, + roles: {cache: {some: () => false}} + }; + await mgmt.voidInfraction(client, interaction, '5'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-gen-no-perm') + })); + }); + + test('reports when the referenced case cannot be found', async () => { + const client = makeClient({Infraction: modelStub({findByPk: jest.fn().mockResolvedValue(null)})}, + { + infractions: {enableInfractions: true}, + configuration: {} + }); + const interaction = voidInteraction(); + await mgmt.voidInfraction(client, interaction, '999'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-no-case-ref') + })); + }); + + test('refuses to void an already-inactive case', async () => { + const record = { + caseId: 3, + active: false, + type: 'Warning' + }; + const client = makeClient({Infraction: modelStub({findByPk: jest.fn().mockResolvedValue(record)})}, + { + infractions: {enableInfractions: true}, + configuration: {} + }); + const interaction = voidInteraction(); + await mgmt.voidInfraction(client, interaction, '3'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-case-inact') + })); + }); + + test('voids a regular infraction', async () => { + const update = jest.fn().mockResolvedValue(); + const record = { + caseId: 3, + active: true, + type: 'Warning', + update + }; + const client = makeClient({Infraction: modelStub({findByPk: jest.fn().mockResolvedValue(record)})}, + { + infractions: {enableInfractions: true}, + configuration: {} + }); + const interaction = voidInteraction(); + await mgmt.voidInfraction(client, interaction, '3'); + expect(update).toHaveBeenCalledWith({active: false}); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('succ-void') + })); + }); + + test('restores suspended roles when voiding a suspension', async () => { + const update = jest.fn().mockResolvedValue(); + const record = { + caseId: 4, + active: true, + type: 'Suspension', + userId: 'target', + update + }; + const profile = { + isSuspended: true, + suspendedRoles: '["r1","r2"]', + update: jest.fn().mockResolvedValue() + }; + const member = { + roles: { + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + } + }; + const client = makeClient({ + Infraction: modelStub({findByPk: jest.fn().mockResolvedValue(record)}), + StaffProfile: modelStub({findOne: jest.fn().mockResolvedValue(profile)}) + }, { + infractions: { + enableInfractions: true, + suspensionRole: 'susp-role' + }, + configuration: {} + }); + const interaction = voidInteraction(); + interaction.guild.members.fetch = jest.fn().mockResolvedValue(member); + await mgmt.voidInfraction(client, interaction, '4'); + expect(member.roles.add).toHaveBeenCalledWith(['r1', 'r2']); + expect(member.roles.remove).toHaveBeenCalledWith('susp-role'); + expect(profile.update).toHaveBeenCalledWith({ + isSuspended: false, + suspendedRoles: '[]' + }); + expect(record.update).toHaveBeenCalledWith({active: false}); + }); +}); \ No newline at end of file diff --git a/tests/staff-management-system/models.test.js b/tests/staff-management-system/models.test.js new file mode 100644 index 00000000..dee708fb --- /dev/null +++ b/tests/staff-management-system/models.test.js @@ -0,0 +1,154 @@ +/* + * Schema tests for every staff-management-system sequelize model. + * + * Each model's static init() forwards an attribute map + options to + * Sequelize.Model.init. We mock the sequelize module so init() simply captures + * those two arguments, letting us assert on the persisted schema without a real + * database: + * - table names + timestamps flags + * - primary keys / autoIncrement + * - NOT NULL columns, defaults, and the StaffReview 1..5 star validator + * - the ActivityCheckResponse unique (activityCheckId,userId) index + * - the static module.exports.config (name + module) for the loader + */ + +jest.mock('sequelize', () => { + const DataTypes = new Proxy({}, { + get: (_t, prop) => { + // STRING is callable (e.g. STRING(1024)) and also usable as a token. + const token = {__type: prop}; + const fn = (...args) => ({ + __type: prop, + args + }); + fn.__type = prop; + return typeof prop === 'string' ? fn : token; + } + }); + + class Model { + static init(attributes, options) { + this._attributes = attributes; + this._options = options; + return this; + } + } + + return { + DataTypes, + Model, + Op: {} + }; +}); + +function load(name) { + const mod = require(`../../modules/staff-management-system/models/${name}`); + const fakeSequelize = {}; + mod.init(fakeSequelize); + return { + attributes: mod._attributes, + options: mod._options, + config: mod.config + }; +} + +describe('staff-management-system models', () => { + test('Infraction: caseId PK autoIncrement, NOT NULL columns, active default', () => { + const { + attributes, + options, + config + } = load('Infraction'); + expect(attributes.caseId.primaryKey).toBe(true); + expect(attributes.caseId.autoIncrement).toBe(true); + expect(attributes.userId.allowNull).toBe(false); + expect(attributes.issuerId.allowNull).toBe(false); + expect(attributes.type.allowNull).toBe(false); + expect(attributes.active.defaultValue).toBe(true); + expect(options.tableName).toBe('staff_management_infractions'); + expect(options.timestamps).toBe(true); + expect(config).toEqual({ + name: 'Infraction', + module: 'staff-management-system' + }); + }); + + test('StaffReview: stars validated to the 1..5 range', () => { + const { + attributes, + config + } = load('StaffReview'); + expect(attributes.stars.allowNull).toBe(false); + expect(attributes.stars.validate).toEqual({ + min: 1, + max: 5 + }); + expect(config.name).toBe('StaffReview'); + }); + + test('StaffProfile: userId PK and sensible duty/status defaults', () => { + const { + attributes, + options + } = load('StaffProfile'); + expect(attributes.userId.primaryKey).toBe(true); + expect(attributes.points.defaultValue).toBe(0); + expect(attributes.onDuty.defaultValue).toBe(false); + expect(attributes.activityStatus.defaultValue).toBe('ACTIVE'); + expect(attributes.isSuspended.defaultValue).toBe(false); + expect(attributes.onBreak.defaultValue).toBe(false); + expect(options.tableName).toBe('staff_management_profiles'); + }); + + test('LoaRequest: required reason/dates, PENDING default status', () => { + const {attributes} = load('LoaRequest'); + expect(attributes.reason.allowNull).toBe(false); + expect(attributes.startDate.allowNull).toBe(false); + expect(attributes.endDate.allowNull).toBe(false); + expect(attributes.status.defaultValue).toBe('PENDING'); + expect(attributes.approverId.allowNull).toBe(true); + }); + + test('Promotion: newRole required, reason optional', () => { + const { + attributes, + options + } = load('Promotion'); + expect(attributes.newRole.allowNull).toBe(false); + expect(attributes.reason.allowNull).toBe(true); + expect(options.tableName).toBe('staff_management_promotions'); + }); + + test('ActivityCheck: ACTIVE default status, isAutomated default false', () => { + const {attributes} = load('ActivityCheck'); + expect(attributes.messageId.allowNull).toBe(false); + expect(attributes.status.defaultValue).toBe('ACTIVE'); + expect(attributes.respondedUsers.defaultValue).toBe('[]'); + expect(attributes.isAutomated.defaultValue).toBe(false); + }); + + test('ActivityCheckResponse: unique (activityCheckId,userId) index', () => { + const { + attributes, + options + } = load('ActivityCheckResponse'); + expect(attributes.activityCheckId.allowNull).toBe(false); + expect(attributes.userId.allowNull).toBe(false); + expect(options.indexes).toEqual([{ + unique: true, + fields: ['activityCheckId', 'userId'] + }]); + }); + + test('StaffShift: type defaults to Staff, breakCount defaults to 0', () => { + const { + attributes, + options + } = load('StaffShift'); + expect(attributes.startTime.allowNull).toBe(false); + expect(attributes.endTime.allowNull).toBe(true); + expect(attributes.type.defaultValue).toBe('Staff'); + expect(attributes.breakCount.defaultValue).toBe(0); + expect(options.tableName).toBe('staff_management_shifts'); + }); +}); \ No newline at end of file diff --git a/tests/staff-management-system/staffStatus.test.js b/tests/staff-management-system/staffStatus.test.js new file mode 100644 index 00000000..7334be7c --- /dev/null +++ b/tests/staff-management-system/staffStatus.test.js @@ -0,0 +1,427 @@ +/* + * Behavior tests for the LOA/RA status logic in commands/staff-status.js. + * + * Covers the exported helpers and request handlers: + * - isStatusTypeEnabled(): the master switch + per-type (LOA/RA) gating + * - sendStatusDm(): builds the right embed per dmType, no-ops on an unknown + * type, and swallows send failures + * - logStatusChange(): respects logStatusChanges, resolves the log channel, + * and bails when disabled / channel missing + * - handleStatusRequest(): disabled gate, duration validation + max-days cap, + * duplicate-active-request guard, PENDING vs auto-APPROVED creation, and the + * role grant + log on the no-approval path + * - handleStatusView(): "no active status" path vs rendering an active request + * - handleStatusList(): the active / expired / history where-clause selection + * and the empty-result message + * - scheduleStatusExpiry(): registers a node-schedule job at the end date and, + * when it fires, ends a still-APPROVED request and clears the role + * + * helpers (formatDate/dateToDiscordTimestamp/safeSetFooter/embedTypeV2) and + * node-schedule are mocked; discord.js builders are real via the shim. + */ + +jest.mock('../../src/functions/helpers', () => ({ + formatDate: jest.fn(() => 'FMT'), + dateToDiscordTimestamp: jest.fn(() => ''), + safeSetFooter: jest.fn((embed) => embed), + embedTypeV2: jest.fn().mockResolvedValue({content: 'rendered'}) +})); +jest.mock('node-schedule', () => ({ + scheduledJobs: {}, + scheduleJob: jest.fn((name, when, cb) => ({ + name, + when, + cb, + cancel: jest.fn() + })) +})); + +const {Op} = require('sequelize'); +const schedule = require('node-schedule'); +const status = require('../../modules/staff-management-system/commands/staff-status'); + +function modelStub(methods = {}) { + return { + findOne: jest.fn().mockResolvedValue(null), + findAll: jest.fn().mockResolvedValue([]), + findByPk: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(0), + create: jest.fn().mockResolvedValue({id: 1}), + update: jest.fn().mockResolvedValue(), + ...methods + }; +} + +function makeClient(models = {}, statusConfig = {}, generalConfig = {}) { + return { + guildID: 'g1', + strings: {footer: 'f'}, + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn() + }, + guilds: {cache: {get: jest.fn().mockReturnValue(null)}}, + users: {fetch: jest.fn().mockResolvedValue(null)}, + models: { + 'staff-management-system': { + LoaRequest: modelStub(), + StaffProfile: modelStub(), + ...models + } + }, + configurations: { + 'staff-management-system': { + status: { + enableStatusSystem: true, + enableLoa: true, + enableRa: true, ...statusConfig + }, + configuration: generalConfig + } + } + }; +} + +describe('isStatusTypeEnabled', () => { + // isStatusTypeEnabled is not directly exported, but its behavior is reachable + // through handleStatusRequest's disabled gate. We test it via that surface. + test('handleStatusRequest refuses when the whole status system is off', async () => { + const client = makeClient({}, {enableStatusSystem: false}); + const interaction = { + user: {id: 'u'}, + editReply: jest.fn().mockResolvedValue() + }; + await status.handleStatusRequest(client, interaction, 'LOA', '5d', 'why'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-status-disabled') + })); + }); + + test('handleStatusRequest refuses LOA when only RA is enabled', async () => { + const client = makeClient({}, { + enableLoa: false, + enableRa: true + }); + const interaction = { + user: {id: 'u'}, + editReply: jest.fn().mockResolvedValue() + }; + await status.handleStatusRequest(client, interaction, 'LOA', '5d', 'why'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-status-disabled') + })); + }); +}); + +describe('sendStatusDm', () => { + function makeUser() { + return { + tag: 'U#1', + send: jest.fn().mockResolvedValue(), + client: { + logger: {error: jest.fn()}, + strings: {footer: 'f'} + } + }; + } + + test('sends an embed for a known dmType', async () => { + const user = makeUser(); + await status.sendStatusDm(user, 'LOA', 'approved', { + approver: 'admin', + endDate: new Date() + }); + expect(user.send).toHaveBeenCalledTimes(1); + expect(user.send.mock.calls[0][0].embeds).toHaveLength(1); + }); + + test('does nothing for an unknown dmType', async () => { + const user = makeUser(); + await status.sendStatusDm(user, 'LOA', 'nonsense', {}); + expect(user.send).not.toHaveBeenCalled(); + }); + + test('swallows send failures and logs them', async () => { + const user = makeUser(); + user.send = jest.fn().mockRejectedValue(new Error('blocked DMs')); + await expect(status.sendStatusDm(user, 'RA', 'denied', { + denier: 'admin', + reason: 'no' + })).resolves.toBeUndefined(); + expect(user.client.logger.error).toHaveBeenCalled(); + }); +}); + +describe('logStatusChange', () => { + test('does nothing when logStatusChanges is disabled', async () => { + const client = makeClient({}, {logStatusChanges: false}); + await status.logStatusChange(client, 'LOA', 'start', {userId: 'u'}); + expect(client.guilds.cache.get).not.toHaveBeenCalled(); + }); + + test('bails when no log channel id is configured', async () => { + const client = makeClient({}, {logStatusChanges: true}); + await status.logStatusChange(client, 'LOA', 'start', {userId: 'u'}); + // No guild lookup beyond the channel resolution short-circuit + expect(client.guilds.cache.get).not.toHaveBeenCalled(); + }); + + test('sends a start log embed to the resolved channel', async () => { + const channel = {send: jest.fn().mockResolvedValue()}; + const guild = {channels: {fetch: jest.fn().mockResolvedValue(channel)}}; + const client = makeClient({}, { + logStatusChanges: true, + statusChangeLogChannel: 'log-chan' + }); + client.guilds.cache.get = jest.fn().mockReturnValue(guild); + await status.logStatusChange(client, 'LOA', 'start', { + userId: 'u', + startDate: new Date(), + endDate: new Date(), + reason: 'trip', + approverId: 'admin' + }); + expect(channel.send).toHaveBeenCalledTimes(1); + expect(channel.send.mock.calls[0][0].embeds).toHaveLength(1); + }); +}); + +describe('handleStatusRequest validation', () => { + function makeInteraction() { + return { + user: { + id: 'u', + toString: () => '<@u>' + }, + member: {roles: {add: jest.fn().mockResolvedValue()}}, + guild: {channels: {fetch: jest.fn().mockResolvedValue(null)}}, + editReply: jest.fn().mockResolvedValue() + }; + } + + test('rejects an unparseable / non-positive duration', async () => { + const client = makeClient(); + const interaction = makeInteraction(); + await status.handleStatusRequest(client, interaction, 'LOA', 'garbage', 'why'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-invalid-duration') + })); + }); + + test('rejects durations beyond the configured max', async () => { + const client = makeClient({}, {loaMaxDays: 7}); + const interaction = makeInteraction(); + await status.handleStatusRequest(client, interaction, 'LOA', '30d', 'why'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-duration-max') + })); + }); + + test('rejects when an overlapping active request already exists', async () => { + const client = makeClient({LoaRequest: modelStub({findOne: jest.fn().mockResolvedValue({id: 9})})}); + const interaction = makeInteraction(); + await status.handleStatusRequest(client, interaction, 'LOA', '5d', 'why'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-status-exists') + })); + }); + + test('creates a PENDING request when approval is required', async () => { + const create = jest.fn().mockResolvedValue({id: 12}); + const client = makeClient({LoaRequest: modelStub({create})}, {requireLoaApproval: true}); + const interaction = makeInteraction(); + await status.handleStatusRequest(client, interaction, 'LOA', '5d', 'vacation'); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + status: 'PENDING', + type: 'LOA', + userId: 'u' + })); + expect(interaction.member.roles.add).not.toHaveBeenCalled(); + }); + + test('auto-approves and grants the role when approval is not required', async () => { + const create = jest.fn().mockResolvedValue({id: 13}); + const client = makeClient( + {LoaRequest: modelStub({create})}, + { + requireLoaApproval: false, + loaRole: 'loa-role' + } + ); + const interaction = makeInteraction(); + await status.handleStatusRequest(client, interaction, 'LOA', '5d', 'vacation'); + expect(create).toHaveBeenCalledWith(expect.objectContaining({status: 'APPROVED'})); + expect(interaction.member.roles.add).toHaveBeenCalledWith('loa-role'); + }); +}); + +describe('handleStatusView', () => { + test('reports when the user has no active status', async () => { + const client = makeClient(); + const interaction = { + user: { + id: 'u', + username: 'U' + }, + editReply: jest.fn().mockResolvedValue() + }; + await status.handleStatusView(client, interaction, 'LOA', null); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('no-active-status') + })); + }); + + test('renders the active request embed', async () => { + const request = { + status: 'APPROVED', + endDate: new Date(), + reason: 'trip' + }; + const client = makeClient({LoaRequest: modelStub({findOne: jest.fn().mockResolvedValue(request)})}); + const user = { + id: 'u', + username: 'U', + displayAvatarURL: () => 'https://cdn.example/a.png' + }; + const interaction = { + user, + editReply: jest.fn().mockResolvedValue() + }; + await status.handleStatusView(client, interaction, 'LOA', user); + const payload = interaction.editReply.mock.calls[0][0]; + expect(payload.embeds).toHaveLength(1); + }); +}); + +describe('handleStatusList', () => { + test('reports an empty result set', async () => { + const client = makeClient(); + const interaction = {editReply: jest.fn().mockResolvedValue()}; + await status.handleStatusList(client, interaction, 'LOA', 'active'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-no-recs') + })); + }); + + test('active filter searches only APPROVED + future-dated rows', async () => { + const findAll = jest.fn().mockResolvedValue([{ + userId: 'u', + status: 'APPROVED', + endDate: new Date(), + reason: 'r' + }]); + const client = makeClient({LoaRequest: modelStub({findAll})}); + const interaction = {editReply: jest.fn().mockResolvedValue()}; + await status.handleStatusList(client, interaction, 'LOA', 'active'); + const where = findAll.mock.calls[0][0].where; + expect(where.status).toBe('APPROVED'); + expect(where.endDate[Op.gt]).toBeInstanceOf(Date); + }); + + test('expired filter searches APPROVED/ENDED within the recent window', async () => { + const findAll = jest.fn().mockResolvedValue([{ + userId: 'u', + status: 'ENDED', + endDate: new Date(), + reason: 'r' + }]); + const client = makeClient({LoaRequest: modelStub({findAll})}); + const interaction = {editReply: jest.fn().mockResolvedValue()}; + await status.handleStatusList(client, interaction, 'LOA', 'expired'); + const where = findAll.mock.calls[0][0].where; + expect(where.status[Op.in]).toEqual(['APPROVED', 'ENDED']); + expect(Array.isArray(where.endDate[Op.between])).toBe(true); + }); +}); + +describe('scheduleStatusExpiry', () => { + beforeEach(() => { + schedule.scheduleJob.mockClear(); + schedule.scheduledJobs = {}; + }); + + test('schedules a job at the request end date', () => { + const client = makeClient(); + const endDate = new Date(Date.now() + 86400000); + status.scheduleStatusExpiry(client, { + id: 7, + endDate + }); + expect(schedule.scheduleJob).toHaveBeenCalledTimes(1); + const [name, when] = schedule.scheduleJob.mock.calls[0]; + expect(name).toBe('staff-mgmt-status-expiry-7'); + expect(when.getTime()).toBe(endDate.getTime()); + }); + + test('cancels an existing job for the same request before re-scheduling', () => { + const cancel = jest.fn(); + schedule.scheduledJobs['staff-mgmt-status-expiry-7'] = {cancel}; + const client = makeClient(); + status.scheduleStatusExpiry(client, { + id: 7, + endDate: new Date(Date.now() + 1000) + }); + expect(cancel).toHaveBeenCalled(); + }); + + test('the fired callback ends a still-APPROVED request and clears the role', async () => { + const req = { + id: 7, + status: 'APPROVED', + type: 'LOA', + userId: 'target', + startDate: new Date(), + endDate: new Date(Date.now() - 1000), + reason: 'r', + update: jest.fn().mockResolvedValue() + }; + const member = { + user: { + tag: 'T#1', + send: jest.fn().mockResolvedValue(), + client: { + logger: {error: jest.fn()}, + strings: {} + } + }, + roles: {remove: jest.fn().mockResolvedValue()} + }; + const guild = {members: {fetch: jest.fn().mockResolvedValue(member)}}; + const client = makeClient( + {LoaRequest: modelStub({findByPk: jest.fn().mockResolvedValue(req)})}, + { + loaRole: 'loa-role', + logStatusChanges: false + } + ); + client.guilds.cache.get = jest.fn().mockReturnValue(guild); + status.scheduleStatusExpiry(client, { + id: 7, + endDate: new Date(Date.now() + 1000) + }); + const cb = schedule.scheduleJob.mock.calls[0][2]; + await cb(); + expect(req.update).toHaveBeenCalledWith({status: 'ENDED'}); + expect(member.roles.remove).toHaveBeenCalledWith('loa-role'); + }); + + test('the fired callback no-ops if the request is no longer APPROVED', async () => { + const req = { + id: 7, + status: 'ENDED', + type: 'LOA', + userId: 'target', + endDate: new Date(Date.now() - 1000), + update: jest.fn() + }; + const client = makeClient({LoaRequest: modelStub({findByPk: jest.fn().mockResolvedValue(req)})}); + status.scheduleStatusExpiry(client, { + id: 7, + endDate: new Date(Date.now() + 1000) + }); + const cb = schedule.scheduleJob.mock.calls[0][2]; + await cb(); + expect(req.update).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/staff-management-system/statusCommandConfig.test.js b/tests/staff-management-system/statusCommandConfig.test.js new file mode 100644 index 00000000..f66cdbf3 --- /dev/null +++ b/tests/staff-management-system/statusCommandConfig.test.js @@ -0,0 +1,106 @@ +/* + * Tests for the /staff-status command's dynamic config + subcommand plumbing + * (commands/staff-status.js), complementing staffStatus.test.js (handlers). + * + * - config.disabled(): true when the status system is off, false when on + * - config.options(): returns no groups when disabled, an LOA-only group when + * only LOA is enabled, and both LOA + RA groups when both are enabled; each + * group exposes request/view/list/admin subcommands + * - beforeSubcommand(): defers ephemerally only when not already replied/deferred + * - subcommands.loa.admin / ra.admin: error when no member is supplied, else + * forward to handleStatusManage with the right type + * + * handleStatusManage et al. come through the real module; we only assert option + * extraction here, so the model layer is stubbed to no-op. + */ + +const status = require('../../modules/staff-management-system/commands/staff-status'); + +function makeClient(statusConfig) { + return {configurations: {'staff-management-system': {status: statusConfig}}}; +} + +describe('config.disabled', () => { + test('disabled when the status system is off', () => { + expect(status.config.disabled(makeClient({enableStatusSystem: false}))).toBe(true); + }); + + test('enabled when the status system is on', () => { + expect(status.config.disabled(makeClient({enableStatusSystem: true}))).toBe(false); + }); +}); + +describe('config.options', () => { + test('returns an empty option set when the system is disabled', () => { + const opts = status.config.options(makeClient({enableStatusSystem: false})); + expect(opts).toEqual([]); + }); + + test('only includes the LOA group when only LOA is enabled', () => { + const opts = status.config.options(makeClient({ + enableStatusSystem: true, + enableLoa: true, + enableRa: false + })); + expect(opts.map(g => g.name)).toEqual(['loa']); + const sub = opts[0].options.map(o => o.name); + expect(sub).toEqual(expect.arrayContaining(['request', 'view', 'list', 'admin'])); + }); + + test('includes both LOA and RA groups when both are enabled', () => { + const opts = status.config.options(makeClient({ + enableStatusSystem: true, + enableLoa: true, + enableRa: true + })); + expect(opts.map(g => g.name)).toEqual(['loa', 'ra']); + }); +}); + +describe('beforeSubcommand', () => { + test('defers ephemerally when not already acknowledged', async () => { + const interaction = { + replied: false, + deferred: false, + deferReply: jest.fn().mockResolvedValue() + }; + await status.beforeSubcommand(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({flags: expect.anything()}); + }); + + test('does not double-defer when already deferred', async () => { + const interaction = { + replied: false, + deferred: true, + deferReply: jest.fn().mockResolvedValue() + }; + await status.beforeSubcommand(interaction); + expect(interaction.deferReply).not.toHaveBeenCalled(); + }); +}); + +describe('admin subcommand member guard', () => { + test('loa.admin errors when no member is supplied', async () => { + const interaction = { + client: {}, + options: {getMember: jest.fn(() => null)}, + editReply: jest.fn().mockResolvedValue() + }; + await status.subcommands.loa.admin(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-no-mem') + })); + }); + + test('ra.admin errors when no member is supplied', async () => { + const interaction = { + client: {}, + options: {getMember: jest.fn(() => null)}, + editReply: jest.fn().mockResolvedValue() + }; + await status.subcommands.ra.admin(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('err-no-mem') + })); + }); +}); \ No newline at end of file diff --git a/tests/starboard/eventsAndExtras.test.js b/tests/starboard/eventsAndExtras.test.js new file mode 100644 index 00000000..01f2673f --- /dev/null +++ b/tests/starboard/eventsAndExtras.test.js @@ -0,0 +1,230 @@ +/* + * Extra coverage for starboard that handleStarboard.test.js leaves out: + * + * - the thin event wrappers (messageReactionAdd / messageReactionRemove) that + * forward to handleStarboard with the correct isReactionRemove flag, and + * declare allowPartial + * - handleStarboard guards: bot-not-ready, non-guild message + * - partial reaction / message fetching before processing + * - nsfw mismatch (nsfw source into a non-nsfw board) is skipped + * - image resolution: archived attachment is used, else a URL scraped from the + * message content, else %image% is null + * + * embedTypeV2 / archiveDiscordAttachment are mocked so we can inspect the + * placeholder map handed to the embed renderer. + */ + +jest.mock('../../src/functions/helpers', () => ({ + embedTypeV2: jest.fn().mockResolvedValue({content: 'rendered'}), + disableModule: jest.fn(), + formatDiscordUserName: (u) => (u && u.tag) || 'user', + archiveDiscordAttachment: jest.fn().mockResolvedValue(null) +})); + +const helpers = require('../../src/functions/helpers'); +const handleStarboard = require('../../modules/starboard/handleStarboard'); +const addEvent = require('../../modules/starboard/events/messageReactionAdd'); +const removeEvent = require('../../modules/starboard/events/messageReactionRemove'); + +jest.mock('../../modules/starboard/handleStarboard'); + +beforeEach(() => { + handleStarboard.mockReset(); + handleStarboard.mockResolvedValue(); +}); + +describe('starboard reaction event wrappers', () => { + test('messageReactionAdd forwards with isReactionRemove=false and allows partials', async () => { + const client = {}; + const reaction = {}; + const user = {id: 'u'}; + await addEvent.run(client, reaction, user); + expect(handleStarboard).toHaveBeenCalledWith(client, reaction, user, false); + expect(addEvent.allowPartial).toBe(true); + }); + + test('messageReactionRemove forwards with isReactionRemove=true and allows partials', async () => { + const client = {}; + const reaction = {}; + const user = {id: 'u'}; + await removeEvent.run(client, reaction, user); + expect(handleStarboard).toHaveBeenCalledWith(client, reaction, user, true); + expect(removeEvent.allowPartial).toBe(true); + }); +}); + +describe('handleStarboard extra branches', () => { + // Use the real handleStarboard for these (un-mock just for this block). + const realHandle = jest.requireActual('../../modules/starboard/handleStarboard'); + + function makeStarConfig(overrides = {}) { + return { + emoji: '⭐', + minStars: 3, + starsPerHour: 5, + selfStar: false, + channelId: 'board', + excludedChannels: [], + excludedRoles: [], + message: 'cfg', ...overrides + }; + } + + function makeMsg(overrides = {}) { + return { + id: 'msg1', + guild: {id: 'g1'}, + partial: false, + url: 'https://d/msg1', + content: '', + channel: { + id: 'src', + name: 'general', + nsfw: false + }, + author: { + id: 'author1', + username: 'Author', + tag: 'Author#1' + }, + member: { + displayName: 'Author', + displayAvatarURL: () => 'avatar', + roles: {cache: {has: () => false}} + }, + attachments: { + size: 0, + first: () => null + }, + fetch: jest.fn().mockResolvedValue(), + ...overrides + }; + } + + function makeReaction(msg, overrides = {}) { + return { + message: msg, + partial: false, + count: 4, + emoji: {toString: () => '⭐'}, + users: { + remove: jest.fn().mockResolvedValue(), + cache: {has: () => false} + }, + fetch: jest.fn(), + ...overrides + }; + } + + function makeClient(cfg, {board} = {}) { + const channel = board || { + nsfw: false, + send: jest.fn().mockResolvedValue({id: 'posted'}), + messages: {fetch: jest.fn().mockResolvedValue(null)} + }; + return { + botReadyAt: Date.now(), + guildID: 'g1', + channels: {cache: {get: (id) => (id === cfg.channelId ? channel : null)}}, + configurations: {starboard: {config: cfg}}, + models: { + starboard: { + StarUser: { + findAll: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue() + }, + StarMsg: { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue(), + destroy: jest.fn() + } + } + }, + _channel: channel + }; + } + + beforeEach(() => { + helpers.embedTypeV2.mockClear().mockResolvedValue({content: 'rendered'}); + helpers.archiveDiscordAttachment.mockClear().mockResolvedValue(null); + }); + + test('does nothing before the bot is ready', async () => { + const cfg = makeStarConfig(); + const client = makeClient(cfg); + client.botReadyAt = null; + await realHandle(client, makeReaction(makeMsg()), {id: 'u'}, false); + expect(client.models.starboard.StarUser.findAll).not.toHaveBeenCalled(); + }); + + test('ignores reactions on messages without a guild', async () => { + const cfg = makeStarConfig(); + const client = makeClient(cfg); + await realHandle(client, makeReaction(makeMsg({guild: null})), {id: 'u'}, false); + expect(client.models.starboard.StarUser.findAll).not.toHaveBeenCalled(); + }); + + test('fetches a partial reaction before processing', async () => { + const cfg = makeStarConfig(); + const client = makeClient(cfg); + const msg = makeMsg(); + const fetched = makeReaction(msg, {emoji: {toString: () => '🔥'}}); // non-matching to short-circuit + const reaction = makeReaction(msg, { + partial: true, + fetch: jest.fn().mockResolvedValue(fetched) + }); + await realHandle(client, reaction, {id: 'u'}, false); + expect(reaction.fetch).toHaveBeenCalled(); + }); + + test('skips an nsfw source message posted to a non-nsfw board', async () => { + const cfg = makeStarConfig(); + const board = { + nsfw: false, + send: jest.fn(), + messages: {fetch: jest.fn().mockResolvedValue(null)} + }; + const client = makeClient(cfg, {board}); + const msg = makeMsg({ + channel: { + id: 'src', + name: 'nsfw', + nsfw: true + } + }); + await realHandle(client, makeReaction(msg), {id: 'u'}, false); + expect(client.models.starboard.StarUser.findAll).not.toHaveBeenCalled(); + }); + + test('uses an archived attachment image when present', async () => { + const cfg = makeStarConfig(); + helpers.archiveDiscordAttachment.mockResolvedValue('https://archive/img.png'); + const client = makeClient(cfg); + const msg = makeMsg({ + attachments: { + size: 1, + first: () => ({url: 'https://d/att.png'}) + } + }); + await realHandle(client, makeReaction(msg, {count: 4}), {id: 'u'}, false); + const placeholders = helpers.embedTypeV2.mock.calls[0][1]; + expect(placeholders['%image%']).toBe('https://archive/img.png'); + }); + + test('falls back to an image URL scraped from the message content', async () => { + const cfg = makeStarConfig(); + const client = makeClient(cfg); + const msg = makeMsg({content: 'look at this https://example.com/pic.jpg cool'}); + await realHandle(client, makeReaction(msg, {count: 4}), {id: 'u'}, false); + const placeholders = helpers.embedTypeV2.mock.calls[0][1]; + expect(placeholders['%image%']).toBe('https://example.com/pic.jpg'); + }); + + test('leaves %image% null when there is no attachment or image URL', async () => { + const cfg = makeStarConfig(); + const client = makeClient(cfg); + await realHandle(client, makeReaction(makeMsg(), {count: 4}), {id: 'u'}, false); + const placeholders = helpers.embedTypeV2.mock.calls[0][1]; + expect(placeholders['%image%']).toBeNull(); + }); +}); \ No newline at end of file diff --git a/tests/starboard/handleStarboard.test.js b/tests/starboard/handleStarboard.test.js new file mode 100644 index 00000000..7d373c44 --- /dev/null +++ b/tests/starboard/handleStarboard.test.js @@ -0,0 +1,307 @@ +/* + * Behavior tests for the starboard reaction handler (handleStarboard.js). + * + * Covers the branching logic that decides whether a starred message is posted, + * updated, or removed from the starboard channel: + * - early-returns: wrong guild, wrong emoji, missing starboard channel, + * excluded channels / roles, nsfw mismatch + * - self-star removal when selfStar is disabled + * - per-hour star-rate limiting (StarUser tally within the last hour) + * - threshold logic: below minStars does nothing on add, and deletes the + * starboard message + DB row on a reaction-remove that drops below minStars + * - posting a NEW starboard message (channel.send + StarMsg.create) when over + * threshold and not yet posted, vs EDITING the existing one + * - self-star vote discounting of the author's own reaction + * + * The Discord embed builder (embedTypeV2) and attachment archiver are mocked so + * the test isolates the handler's decision logic, not embed formatting. + */ + +jest.mock('../../src/functions/helpers', () => ({ + embedTypeV2: jest.fn().mockResolvedValue({content: 'rendered'}), + disableModule: jest.fn(), + formatDiscordUserName: (u) => (u && u.tag) || 'user', + archiveDiscordAttachment: jest.fn().mockResolvedValue(null) +})); + +const helpers = require('../../src/functions/helpers'); +const handleStarboard = require('../../modules/starboard/handleStarboard'); + +function makeStarConfig(overrides = {}) { + return { + emoji: '⭐', + minStars: 3, + starsPerHour: 5, + selfStar: false, + channelId: 'starboard-chan', + excludedChannels: [], + excludedRoles: [], + message: 'cfg-message', + ...overrides + }; +} + +function makeMsg(overrides = {}) { + return { + id: 'msg1', + guild: {id: 'g1'}, + partial: false, + url: 'https://discord/msg1', + content: '', + channel: { + id: 'src-chan', + name: 'general', + nsfw: false + }, + author: { + id: 'author1', + username: 'Author', + tag: 'Author#1' + }, + member: { + displayName: 'Author', + displayAvatarURL: () => 'avatar', + roles: {cache: {has: () => false}} + }, + attachments: { + size: 0, + first: () => null + }, + fetch: jest.fn().mockResolvedValue(), + ...overrides + }; +} + +function makeReaction(msg, overrides = {}) { + return { + message: msg, + partial: false, + count: 4, + emoji: {toString: () => '⭐'}, + users: { + remove: jest.fn().mockResolvedValue(), + cache: {has: () => false} + }, + ...overrides + }; +} + +function makeClient(starConfig, { + starUsers = [], + starMsg = null, + starboardChannel +} = {}) { + const channel = starboardChannel || { + nsfw: false, + send: jest.fn().mockResolvedValue({id: 'posted-msg'}), + messages: {fetch: jest.fn().mockResolvedValue(null)} + }; + return { + botReadyAt: Date.now(), + guildID: 'g1', + channels: {cache: {get: (id) => (id === starConfig.channelId ? channel : null)}}, + configurations: {starboard: {config: starConfig}}, + models: { + starboard: { + StarUser: { + findAll: jest.fn().mockResolvedValue(starUsers), + create: jest.fn().mockResolvedValue() + }, + StarMsg: { + findOne: jest.fn().mockResolvedValue(starMsg), + create: jest.fn().mockResolvedValue(), + destroy: jest.fn().mockResolvedValue() + } + } + }, + _channel: channel + }; +} + +beforeEach(() => { + helpers.embedTypeV2.mockClear(); + helpers.embedTypeV2.mockResolvedValue({content: 'rendered'}); + helpers.disableModule.mockClear(); +}); + +describe('starboard guard clauses', () => { + test('ignores reactions from other guilds', async () => { + const cfg = makeStarConfig(); + const client = makeClient(cfg); + const msg = makeMsg({guild: {id: 'other-guild'}}); + const reaction = makeReaction(msg); + await handleStarboard(client, reaction, {id: 'u1'}, false); + expect(client.models.starboard.StarUser.create).not.toHaveBeenCalled(); + expect(client._channel.send).not.toHaveBeenCalled(); + }); + + test('ignores reactions with a non-matching emoji', async () => { + const cfg = makeStarConfig(); + const client = makeClient(cfg); + const msg = makeMsg(); + const reaction = makeReaction(msg, {emoji: {toString: () => '🔥'}}); + await handleStarboard(client, reaction, {id: 'u1'}, false); + expect(client.models.starboard.StarUser.findAll).not.toHaveBeenCalled(); + }); + + test('disables the module when minStars is not a number', async () => { + const cfg = makeStarConfig({minStars: 'abc'}); + const client = makeClient(cfg); + await handleStarboard(client, makeReaction(makeMsg()), {id: 'u1'}, false); + expect(helpers.disableModule).toHaveBeenCalledWith('starboard', expect.any(String)); + }); + + test('disables the module when the starboard channel is missing', async () => { + const cfg = makeStarConfig(); + const client = makeClient(cfg); + client.channels.cache.get = () => null; + await handleStarboard(client, makeReaction(makeMsg()), {id: 'u1'}, false); + expect(helpers.disableModule).toHaveBeenCalledWith('starboard', expect.any(String)); + }); + + test('ignores reactions in excluded channels', async () => { + const cfg = makeStarConfig({excludedChannels: ['src-chan']}); + const client = makeClient(cfg); + await handleStarboard(client, makeReaction(makeMsg()), {id: 'u1'}, false); + expect(client.models.starboard.StarUser.findAll).not.toHaveBeenCalled(); + }); + + test('ignores reactions from members with an excluded role', async () => { + const cfg = makeStarConfig({excludedRoles: ['role-x']}); + const client = makeClient(cfg); + const msg = makeMsg(); + msg.member.roles.cache.has = (r) => r === 'role-x'; + await handleStarboard(client, makeReaction(msg), {id: 'u1'}, false); + expect(client.models.starboard.StarUser.findAll).not.toHaveBeenCalled(); + }); +}); + +describe('self-star handling', () => { + test('removes the reaction when a user stars their own message and selfStar is off', async () => { + const cfg = makeStarConfig({selfStar: false}); + const client = makeClient(cfg); + const msg = makeMsg(); + const reaction = makeReaction(msg); + await handleStarboard(client, reaction, {id: 'author1'}, false); + expect(reaction.users.remove).toHaveBeenCalledWith('author1'); + expect(client.models.starboard.StarUser.create).not.toHaveBeenCalled(); + }); + + test('allows self-stars when selfStar is enabled', async () => { + const cfg = makeStarConfig({selfStar: true}); + const client = makeClient(cfg); + const msg = makeMsg(); + const reaction = makeReaction(msg, {count: 4}); + await handleStarboard(client, reaction, {id: 'author1'}, false); + expect(client.models.starboard.StarUser.create).toHaveBeenCalled(); + }); +}); + +describe('per-hour rate limiting', () => { + test('blocks and removes the star once the hourly limit is reached', async () => { + const cfg = makeStarConfig({starsPerHour: 2}); + const starUsers = [ + {dataValues: {createdAt: Date.now()}}, + {dataValues: {createdAt: Date.now()}} + ]; + const client = makeClient(cfg, {starUsers}); + const msg = makeMsg(); + const reaction = makeReaction(msg); + const user = { + id: 'u1', + send: jest.fn().mockResolvedValue() + }; + await handleStarboard(client, reaction, user, false); + expect(user.send).toHaveBeenCalled(); + expect(reaction.users.remove).toHaveBeenCalledWith('u1'); + expect(client.models.starboard.StarUser.create).not.toHaveBeenCalled(); + }); +}); + +describe('threshold logic', () => { + test('does nothing on add when the count is below minStars', async () => { + const cfg = makeStarConfig({minStars: 5}); + const client = makeClient(cfg); + const reaction = makeReaction(makeMsg(), {count: 4}); + await handleStarboard(client, reaction, {id: 'u1'}, false); + // It still records the star, but never posts to the board. + expect(client._channel.send).not.toHaveBeenCalled(); + expect(helpers.embedTypeV2).not.toHaveBeenCalled(); + }); + + test('deletes the starboard message and DB row when a remove drops below minStars', async () => { + const cfg = makeStarConfig({minStars: 5}); + const starboardMsg = { + delete: jest.fn(), + edit: jest.fn() + }; + const channel = { + nsfw: false, + send: jest.fn(), + messages: {fetch: jest.fn().mockResolvedValue(starboardMsg)} + }; + const client = makeClient(cfg, { + starMsg: {starMsg: 'sb-msg'}, + starboardChannel: channel + }); + const reaction = makeReaction(makeMsg(), {count: 2}); + await handleStarboard(client, reaction, {id: 'u1'}, true); + expect(starboardMsg.delete).toHaveBeenCalled(); + expect(client.models.starboard.StarMsg.destroy).toHaveBeenCalledWith({where: {msgId: 'msg1'}}); + }); + + test('posts a NEW starboard message when over threshold and none exists yet', async () => { + const cfg = makeStarConfig({minStars: 3}); + const client = makeClient(cfg, {starMsg: null}); + const reaction = makeReaction(makeMsg(), {count: 4}); + await handleStarboard(client, reaction, {id: 'u1'}, false); + expect(client._channel.send).toHaveBeenCalledWith({content: 'rendered'}); + expect(client.models.starboard.StarMsg.create).toHaveBeenCalledWith( + expect.objectContaining({ + msgId: 'msg1', + starMsg: 'posted-msg' + }) + ); + }); + + test('EDITS the existing starboard message instead of re-posting', async () => { + const cfg = makeStarConfig({minStars: 3}); + const starboardMsg = { + edit: jest.fn(), + delete: jest.fn() + }; + const channel = { + nsfw: false, + send: jest.fn(), + messages: {fetch: jest.fn().mockResolvedValue(starboardMsg)} + }; + const client = makeClient(cfg, { + starMsg: {starMsg: 'sb-msg'}, + starboardChannel: channel + }); + const reaction = makeReaction(makeMsg(), {count: 4}); + await handleStarboard(client, reaction, {id: 'u1'}, false); + expect(starboardMsg.edit).toHaveBeenCalledWith({content: 'rendered'}); + expect(channel.send).not.toHaveBeenCalled(); + expect(client.models.starboard.StarMsg.create).not.toHaveBeenCalled(); + }); + + test('discounts the author own reaction from the count when selfStar is off', async () => { + // count is 3 but one of them is the author's, so effective count is 2 < minStars(3) + const cfg = makeStarConfig({ + minStars: 3, + selfStar: false + }); + const client = makeClient(cfg); + const msg = makeMsg(); + const reaction = makeReaction(msg, { + count: 3, + users: { + remove: jest.fn(), + cache: {has: (id) => id === 'author1'} + } + }); + await handleStarboard(client, reaction, {id: 'u1'}, false); + expect(client._channel.send).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/starboard/models.test.js b/tests/starboard/models.test.js new file mode 100644 index 00000000..c6205c69 --- /dev/null +++ b/tests/starboard/models.test.js @@ -0,0 +1,66 @@ +/* + * Schema tests for the starboard sequelize models (StarMsg, StarUser). + * + * sequelize is mocked so each model's static init() just records the attribute + * map + options, letting us assert the persisted column set, table names, + * timestamps flag and the loader config without a real database. + */ + +jest.mock('sequelize', () => { + const DataTypes = new Proxy({}, {get: (_t, prop) => ({__type: prop})}); + + class Model { + static init(attributes, options) { + this._attributes = attributes; + this._options = options; + return this; + } + } + + return { + DataTypes, + Model + }; +}); + +function load(name) { + const mod = require(`../../modules/starboard/models/${name}`); + mod.init({}); + return { + attributes: mod._attributes, + options: mod._options, + config: mod.config + }; +} + +describe('starboard models', () => { + test('StarMsg maps a source message to its starboard message', () => { + const { + attributes, + options, + config + } = load('StarMsg'); + expect(Object.keys(attributes).sort()).toEqual(['msgId', 'starMsg']); + expect(options.tableName).toBe('starboard_StarMsg'); + expect(options.timestamps).toBe(true); + expect(config).toEqual({ + name: 'StarMsg', + module: 'starboard' + }); + }); + + test('StarUser records who starred which message (for rate limiting)', () => { + const { + attributes, + options, + config + } = load('StarUser'); + expect(Object.keys(attributes).sort()).toEqual(['msgId', 'userId']); + expect(options.tableName).toBe('starboard_StarUser'); + expect(options.timestamps).toBe(true); + expect(config).toEqual({ + name: 'StarUser', + module: 'starboard' + }); + }); +}); \ No newline at end of file diff --git a/tests/status-roles/presenceUpdate.test.js b/tests/status-roles/presenceUpdate.test.js new file mode 100644 index 00000000..6f7bfa5d --- /dev/null +++ b/tests/status-roles/presenceUpdate.test.js @@ -0,0 +1,166 @@ +/* + * Behavior tests for the status-roles presenceUpdate handler. + * + * The handler grants configured roles to members whose custom status text + * contains a configured keyword, and removes them otherwise. Covers: + * - guard clauses (bot not ready, no member, wrong guild) + * - case-insensitive substring matching of the custom status against keywords + * - only Custom activities (ActivityType.Custom) are considered + * - not re-adding when the member already holds all roles + * - removing roles when the status no longer matches + * - the ignoreOfflineUsers option skipping removal for offline members + */ + +const {ActivityType} = require('discord.js'); +const handler = require('../../modules/status-roles/events/presenceUpdate'); + +function makeRoleCache(roleIds) { + return { + filter(fn) { + return makeRoleCache(roleIds.filter(id => fn({ + id, + managed: false + }))); + }, + get size() { + return roleIds.length; + } + }; +} + +function makeClient(configOverrides = {}) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + configurations: { + 'status-roles': { + config: { + roles: ['role1'], + words: ['scootkit'], + remove: false, + ignoreOfflineUsers: false, + ...configOverrides + } + } + } + }; +} + +function makePresence({ + statusText = null, + memberRoles = [], + status = 'online', + guildId = 'g1', + hasMember = true + } = {}) { + const activities = statusText === null + ? [] + : [{ + type: ActivityType.Custom, + state: statusText + }]; + const member = hasMember ? { + guild: {id: guildId}, + roles: { + cache: makeRoleCache(memberRoles), + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + } + } : null; + return { + member, + activities, + status + }; +} + +describe('status-roles guards', () => { + test('does nothing before the bot is ready', async () => { + const client = { + ...makeClient(), + botReadyAt: null + }; + const presence = makePresence({statusText: 'scootkit'}); + await handler.run(client, null, presence); + expect(presence.member.roles.add).not.toHaveBeenCalled(); + }); + + test('does nothing when there is no member', async () => { + const client = makeClient(); + const presence = makePresence({hasMember: true}); + presence.member = null; + await expect(handler.run(client, null, presence)).resolves.toBeUndefined(); + }); + + test('ignores presences from other guilds', async () => { + const client = makeClient(); + const presence = makePresence({ + statusText: 'scootkit', + guildId: 'other' + }); + await handler.run(client, null, presence); + expect(presence.member.roles.add).not.toHaveBeenCalled(); + }); +}); + +describe('status matching', () => { + test('adds the configured roles when the status contains a keyword (case-insensitive)', async () => { + const client = makeClient(); + const presence = makePresence({statusText: 'I love ScootKit servers'}); + await handler.run(client, null, presence); + expect(presence.member.roles.add).toHaveBeenCalledWith(['role1'], expect.any(String)); + }); + + test('does not re-add when the member already has all roles', async () => { + const client = makeClient(); + const presence = makePresence({ + statusText: 'scootkit', + memberRoles: ['role1'] + }); + await handler.run(client, null, presence); + expect(presence.member.roles.add).not.toHaveBeenCalled(); + }); + + test('ignores non-custom activities', async () => { + const client = makeClient(); + const presence = makePresence({statusText: 'scootkit'}); + // Make the only activity a non-custom one. + presence.activities = [{ + type: ActivityType.Playing, + state: 'scootkit' + }]; + await handler.run(client, null, presence); + expect(presence.member.roles.add).not.toHaveBeenCalled(); + }); + + test('removes the roles when the status no longer matches', async () => { + const client = makeClient(); + const presence = makePresence({ + statusText: 'unrelated', + memberRoles: ['role1'] + }); + await handler.run(client, null, presence); + expect(presence.member.roles.remove).toHaveBeenCalledWith(['role1'], expect.any(String)); + }); + + test('does not attempt removal when the member has none of the roles', async () => { + const client = makeClient(); + const presence = makePresence({ + statusText: 'unrelated', + memberRoles: [] + }); + await handler.run(client, null, presence); + expect(presence.member.roles.remove).not.toHaveBeenCalled(); + }); + + test('skips removal for offline members when ignoreOfflineUsers is set', async () => { + const client = makeClient({ignoreOfflineUsers: true}); + const presence = makePresence({ + statusText: 'unrelated', + memberRoles: ['role1'], + status: 'offline' + }); + await handler.run(client, null, presence); + expect(presence.member.roles.remove).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/status-roles/removeBranch.test.js b/tests/status-roles/removeBranch.test.js new file mode 100644 index 00000000..a4201966 --- /dev/null +++ b/tests/status-roles/removeBranch.test.js @@ -0,0 +1,134 @@ +/* + * Additional edge coverage for the status-roles presenceUpdate handler that the + * existing presenceUpdate.test.js does not exercise: + * + * - when the status matches and moduleConfig.remove is enabled, all non-managed + * roles are stripped before the configured roles are (re-)added + * - managed roles are never stripped during that purge + * - multiple configured roles: the "already has all roles" short-circuit only + * triggers when the member holds the full set + * - an offline member whose status no longer matches still has roles removed + * when ignoreOfflineUsers is off + */ + +const {ActivityType} = require('discord.js'); +const handler = require('../../modules/status-roles/events/presenceUpdate'); + +function roleCache(roles) { + // roles: array of {id, managed} + return { + filter(fn) { + return roleCache(roles.filter(fn)); + }, + get size() { + return roles.length; + }, + _roles: roles + }; +} + +function makeClient(configOverrides = {}) { + return { + botReadyAt: Date.now(), + guildID: 'g1', + configurations: { + 'status-roles': { + config: { + roles: ['role1'], + words: ['scootkit'], + remove: false, + ignoreOfflineUsers: false, ...configOverrides + } + } + } + }; +} + +function makePresence({ + statusText = null, + memberRoles = [], + status = 'online' + } = {}) { + const activities = statusText === null ? [] : [{ + type: ActivityType.Custom, + state: statusText + }]; + return { + status, + activities, + member: { + guild: {id: 'g1'}, + roles: { + cache: roleCache(memberRoles), + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + } + } + }; +} + +describe('status-roles remove-other-roles branch', () => { + test('strips non-managed roles before adding the configured role when remove is on', async () => { + const client = makeClient({ + remove: true, + roles: ['role1'] + }); + const presence = makePresence({ + statusText: 'scootkit', + memberRoles: [{ + id: 'old', + managed: false + }, { + id: 'boost', + managed: true + }] + }); + await handler.run(client, null, presence); + // remove() was called with a (filtered) collection of non-managed roles + const removedArg = presence.member.roles.remove.mock.calls[0][0]; + expect(removedArg._roles.map(r => r.id)).toEqual(['old']); // managed boost excluded + expect(presence.member.roles.add).toHaveBeenCalledWith(['role1'], expect.any(String)); + }); + + test('does not purge other roles when remove is off', async () => { + const client = makeClient({remove: false}); + const presence = makePresence({ + statusText: 'scootkit', + memberRoles: [{ + id: 'old', + managed: false + }] + }); + await handler.run(client, null, presence); + expect(presence.member.roles.remove).not.toHaveBeenCalled(); + expect(presence.member.roles.add).toHaveBeenCalled(); + }); + + test('does not re-add only when the member holds ALL configured roles', async () => { + const client = makeClient({roles: ['role1', 'role2']}); + // holds only role1 -> still needs role2, so add fires + const presence = makePresence({ + statusText: 'scootkit', + memberRoles: [{ + id: 'role1', + managed: false + }] + }); + await handler.run(client, null, presence); + expect(presence.member.roles.add).toHaveBeenCalledWith(['role1', 'role2'], expect.any(String)); + }); + + test('removes roles from an offline non-matching member when ignoreOfflineUsers is off', async () => { + const client = makeClient({ignoreOfflineUsers: false}); + const presence = makePresence({ + statusText: 'unrelated', + memberRoles: [{ + id: 'role1', + managed: false + }], + status: 'offline' + }); + await handler.run(client, null, presence); + expect(presence.member.roles.remove).toHaveBeenCalledWith(['role1'], expect.any(String)); + }); +}); \ No newline at end of file diff --git a/tests/sticky-messages/deleteAndSend.test.js b/tests/sticky-messages/deleteAndSend.test.js new file mode 100644 index 00000000..7c7721e1 --- /dev/null +++ b/tests/sticky-messages/deleteAndSend.test.js @@ -0,0 +1,147 @@ +/* + * Direct tests for the sticky-messages helper functions (deleteMessage / + * sendMessage) plus the debounce-timer-fires path, complementing + * messageCreate.test.js (which drives them through run()). + * + * - sendMessage(): renders via embedTypeV2 and posts to the channel, recording + * the sent message id in the per-channel state + * - deleteMessage(): no-ops for an unknown channel; deletes the tracked message + * when found; falls back to scanning recent messages for one authored by the + * bot when the tracked fetch fails + * - the debounced run(): after the 5s window elapses, the scheduled timeout + * deletes the previous sticky and re-sends it + * + * embedTypeV2 is mocked; timers are faked. + */ + +jest.mock('../../src/functions/helpers', () => ({ + embedTypeV2: jest.fn(async (m) => ({content: 'sticky:' + m})) +})); + +let handler; +let helpers; + +beforeEach(() => { + jest.resetModules(); + jest.useFakeTimers(); + // re-grab the fresh helpers mock instance the handler will use + helpers = require('../../src/functions/helpers'); + helpers.embedTypeV2.mockClear(); + handler = require('../../modules/sticky-messages/events/messageCreate'); +}); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +describe('sendMessage', () => { + test('renders and posts the configured sticky', async () => { + const sent = {id: 'sent-1'}; + const channel = { + id: 'c1', + send: jest.fn().mockResolvedValue(sent) + }; + await handler.sendMessage(channel, 'welcome'); + expect(helpers.embedTypeV2).toHaveBeenCalledWith('welcome'); + expect(channel.send).toHaveBeenCalledWith({content: 'sticky:welcome'}); + }); +}); + +describe('deleteMessage', () => { + test('no-ops for a channel with no tracked sticky', async () => { + const channel = { + id: 'never-used', + messages: {fetch: jest.fn()} + }; + await handler.deleteMessage('bot', channel); + expect(channel.messages.fetch).not.toHaveBeenCalled(); + }); + + test('deletes the tracked sticky message', async () => { + const stickyMsg = { + deletable: true, + delete: jest.fn().mockResolvedValue() + }; + const channel = { + id: 'c-del', + send: jest.fn().mockResolvedValue({id: 'sent-x'}), + messages: {fetch: jest.fn().mockResolvedValue(stickyMsg)} + }; + // establish tracked state for this channel + await handler.sendMessage(channel, 'hi'); + await handler.deleteMessage('bot', channel); + expect(stickyMsg.delete).toHaveBeenCalled(); + }); + + test('falls back to scanning recent messages when the tracked fetch fails', async () => { + const botMsg = { + author: {id: 'bot'}, + delete: jest.fn().mockResolvedValue() + }; + const recent = {find: (fn) => ([botMsg].find(fn))}; + const channel = { + id: 'c-fallback', + send: jest.fn().mockResolvedValue({id: 'sent-y'}), + messages: { + fetch: jest.fn((arg) => { + // the limit:20 scan resolves; the tracked-id fetch rejects so + // the handler falls back to scanning recent messages + if (arg && arg.limit) return Promise.resolve(recent); + return Promise.reject(new Error('gone')); + }) + } + }; + await handler.sendMessage(channel, 'hi'); + await handler.deleteMessage('bot', channel); + expect(botMsg.delete).toHaveBeenCalled(); + }); +}); + +describe('debounced timeout fires a refresh', () => { + test('after the window, the scheduled timeout deletes and re-sends', async () => { + const stickyMsg = { + deletable: true, + delete: jest.fn().mockResolvedValue() + }; + const channel = { + id: 'burst', + send: jest.fn().mockResolvedValue({ + id: 'sent-z', + deletable: true, + delete: jest.fn() + }), + messages: {fetch: jest.fn().mockResolvedValue(stickyMsg)} + }; + const client = { + botReadyAt: Date.now(), + user: {id: 'bot'}, + guildID: 'g1', + configurations: { + 'sticky-messages': { + 'sticky-messages': [{ + channelId: 'burst', + message: 'welcome' + }] + } + } + }; + const msg = { + guild: {id: 'g1'}, + member: {}, + channel, + author: { + id: 'human', + bot: false + } + }; + + await handler.run(client, msg); // first send -> sets time = now + channel.send.mockClear(); + await handler.run(client, msg); // within window -> schedules a 5s timeout + + jest.advanceTimersByTime(5000); // fire the debounce timeout + await Promise.resolve(); + await Promise.resolve(); + expect(channel.send).toHaveBeenCalled(); // re-sent after the timeout + }); +}); \ No newline at end of file diff --git a/tests/sticky-messages/messageCreate.test.js b/tests/sticky-messages/messageCreate.test.js new file mode 100644 index 00000000..92a0579c --- /dev/null +++ b/tests/sticky-messages/messageCreate.test.js @@ -0,0 +1,167 @@ +/* + * Behavior tests for the sticky-messages messageCreate handler. + * + * The handler keeps a configured "sticky" message pinned to the bottom of a + * channel: when someone posts, it deletes the old sticky and re-sends it, but + * debounces rapid bursts (a 5s window). Covers: + * - guard clauses (not ready, no guild, wrong guild, no member) + * - channels with no sticky config are ignored + * - the bot's own freshly-sent sticky does not retrigger (sendPending guard) + * - bot authors are ignored unless respondBots is enabled + * - first message in a channel sends the sticky immediately + * - a second message within 5s is debounced (schedules a timeout, no immediate + * re-send), while a message after the window re-sends immediately + * + * embedTypeV2 is mocked so we assert on send/delete orchestration. + */ + +jest.mock('../../src/functions/helpers', () => ({ + embedTypeV2: jest.fn(async (m) => ({content: 'sticky:' + m})) +})); + +let handler; +beforeEach(() => { + jest.resetModules(); + jest.useFakeTimers(); + handler = require('../../modules/sticky-messages/events/messageCreate'); +}); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +function makeChannel(id = 'chan1') { + return { + id, + send: jest.fn().mockResolvedValue({ + id: 'sent-' + id, + deletable: true, + delete: jest.fn().mockResolvedValue() + }), + messages: { + fetch: jest.fn().mockResolvedValue({ + deletable: true, + delete: jest.fn().mockResolvedValue() + }) + } + }; +} + +function makeClient(stickyChannels) { + return { + botReadyAt: Date.now(), + user: {id: 'bot'}, + configurations: {'sticky-messages': {'sticky-messages': stickyChannels}} + }; +} + +function makeMsg(channel, { + authorId = 'human', + bot = false, + guild = {id: 'g1'}, + member = {} +} = {}) { + return { + guild, + member, + channel, + author: { + id: authorId, + bot + } + }; +} + +const guildId = 'g1'; + +function clientForGuild(stickyChannels) { + const c = makeClient(stickyChannels); + c.config = {guildID: guildId}; + c.guildID = guildId; + return c; +} + +describe('sticky-messages guards', () => { + test('ignores messages before the bot is ready', async () => { + const channel = makeChannel(); + const client = clientForGuild([{ + channelId: channel.id, + message: 'hi' + }]); + client.botReadyAt = null; + await handler.run(client, makeMsg(channel)); + expect(channel.send).not.toHaveBeenCalled(); + }); + + test('ignores messages outside the configured guild', async () => { + const channel = makeChannel(); + const client = clientForGuild([{ + channelId: channel.id, + message: 'hi' + }]); + await handler.run(client, makeMsg(channel, {guild: {id: 'other'}})); + expect(channel.send).not.toHaveBeenCalled(); + }); + + test('ignores channels without a sticky configuration', async () => { + const channel = makeChannel('unconfigured'); + const client = clientForGuild([{ + channelId: 'someother', + message: 'hi' + }]); + await handler.run(client, makeMsg(channel)); + expect(channel.send).not.toHaveBeenCalled(); + }); + + test('ignores bot authors unless respondBots is enabled', async () => { + const channel = makeChannel(); + const client = clientForGuild([{ + channelId: channel.id, + message: 'hi', + respondBots: false + }]); + await handler.run(client, makeMsg(channel, {bot: true})); + expect(channel.send).not.toHaveBeenCalled(); + }); +}); + +describe('sticky-messages send / debounce', () => { + test('sends the sticky on the first human message in the channel', async () => { + const channel = makeChannel('firstchan'); + const client = clientForGuild([{ + channelId: channel.id, + message: 'welcome' + }]); + await handler.run(client, makeMsg(channel)); + expect(channel.send).toHaveBeenCalledTimes(1); + expect(channel.send).toHaveBeenCalledWith({content: 'sticky:welcome'}); + }); + + test('debounces a rapid follow-up message within the 5s window', async () => { + const channel = makeChannel('burstchan'); + const client = clientForGuild([{ + channelId: channel.id, + message: 'welcome' + }]); + await handler.run(client, makeMsg(channel)); // first send + channel.send.mockClear(); + + await handler.run(client, makeMsg(channel)); // within window -> debounced + expect(channel.send).not.toHaveBeenCalled(); + }); + + test('re-sends immediately for a message after the 5s window', async () => { + const channel = makeChannel('slowchan'); + const client = clientForGuild([{ + channelId: channel.id, + message: 'welcome' + }]); + await handler.run(client, makeMsg(channel)); // first send sets time = now + await Promise.resolve(); + channel.send.mockClear(); + + jest.advanceTimersByTime(6000); // move past the 5s window + await handler.run(client, makeMsg(channel)); + expect(channel.send).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/suggestions/manageSuggestion.test.js b/tests/suggestions/manageSuggestion.test.js new file mode 100644 index 00000000..1c4f8305 --- /dev/null +++ b/tests/suggestions/manageSuggestion.test.js @@ -0,0 +1,156 @@ +/* + * Behavior tests for the manage-suggestion command + * (commands/manage-suggestion.js). + * + * Covers: + * - beforeSubcommand(): looks the suggestion up by id; if missing it replies + * with an error and flags returnEarly; otherwise it defers + * - run(): writes the adminAnswer (action/reason/userID), saves, regenerates + * the embed and notifies members; and is a no-op when returnEarly is set + * - autoCompleteSuggestionID(): filters un-answered suggestions by id / + * content / suggester and caps the result list at 25 entries + * + * The sibling suggestion module (generateSuggestionEmbed/notifyMembers) and + * helpers are mocked so we test the command's own orchestration. + */ + +jest.mock('../../modules/suggestions/suggestion', () => ({ + generateSuggestionEmbed: jest.fn().mockResolvedValue(), + notifyMembers: jest.fn().mockResolvedValue() +})); +jest.mock('../../src/functions/helpers', () => ({ + truncate: (s) => s, + formatDiscordUserName: (u) => (u && u.tag) || 'unknown' +})); + +const { + generateSuggestionEmbed, + notifyMembers +} = require('../../modules/suggestions/suggestion'); +const cmd = require('../../modules/suggestions/commands/manage-suggestion'); + +function makeInteraction(overrides = {}) { + return { + options: {getString: jest.fn((k) => overrides.opts?.[k])}, + client: { + models: { + suggestions: { + Suggestion: { + findOne: jest.fn(), + findAll: jest.fn() + } + } + }, + guild: {members: {cache: {get: () => null}}} + }, + user: {id: 'admin1'}, + reply: jest.fn().mockResolvedValue(), + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + respond: jest.fn(), + ...overrides + }; +} + +beforeEach(() => { + generateSuggestionEmbed.mockClear(); + notifyMembers.mockClear(); +}); + +describe('beforeSubcommand', () => { + test('replies with an error and flags returnEarly when the suggestion is missing', async () => { + const interaction = makeInteraction({opts: {id: '999'}}); + interaction.client.models.suggestions.Suggestion.findOne = jest.fn().mockResolvedValue(null); + await cmd.beforeSubcommand(interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('suggestions.suggestion-not-found') + })); + expect(interaction.returnEarly).toBe(true); + expect(interaction.deferReply).not.toHaveBeenCalled(); + }); + + test('defers and stores the suggestion when found', async () => { + const suggestion = {id: 5}; + const interaction = makeInteraction({opts: {id: '5'}}); + interaction.client.models.suggestions.Suggestion.findOne = jest.fn().mockResolvedValue(suggestion); + await cmd.beforeSubcommand(interaction); + expect(interaction.suggestion).toBe(suggestion); + expect(interaction.deferReply).toHaveBeenCalledWith({ephemeral: true}); + expect(interaction.reply).not.toHaveBeenCalled(); + }); +}); + +describe('run', () => { + test('is a no-op when returnEarly is set', async () => { + const interaction = makeInteraction(); + interaction.returnEarly = true; + await cmd.run(interaction); + expect(generateSuggestionEmbed).not.toHaveBeenCalled(); + expect(interaction.editReply).not.toHaveBeenCalled(); + }); + + test('writes the adminAnswer, saves, regenerates the embed and notifies', async () => { + const save = jest.fn().mockResolvedValue(); + const interaction = makeInteraction({opts: {comment: 'looks good'}}); + interaction.editType = 'approve'; + interaction.suggestion = {save}; + await cmd.run(interaction); + expect(interaction.suggestion.adminAnswer).toEqual({ + action: 'approve', + reason: 'looks good', + userID: 'admin1' + }); + expect(save).toHaveBeenCalled(); + expect(generateSuggestionEmbed).toHaveBeenCalledWith(interaction.client, interaction.suggestion); + expect(notifyMembers).toHaveBeenCalledWith(interaction.client, interaction.suggestion, 'team', 'admin1'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('suggestions.updated-suggestion') + })); + }); +}); + +describe('autoCompleteSuggestionID', () => { + function suggestionRow(id, text) { + return { + id, + suggestion: text, + messageID: 'msg-' + id, + suggesterID: 'u' + id + }; + } + + test('filters by suggestion content (case-insensitive)', async () => { + const interaction = makeInteraction(); + interaction.value = 'DARK'; + interaction.client.models.suggestions.Suggestion.findAll = jest.fn().mockResolvedValue([ + suggestionRow(1, 'add dark mode'), + suggestionRow(2, 'unrelated feature') + ]); + await cmd.autoCompleteSuggestionID(interaction); + expect(interaction.respond).toHaveBeenCalledTimes(1); + const choices = interaction.respond.mock.calls[0][0]; + expect(choices).toHaveLength(1); + expect(choices[0].value).toBe('1'); + }); + + test('matches by numeric id', async () => { + const interaction = makeInteraction(); + interaction.value = '42'; + interaction.client.models.suggestions.Suggestion.findAll = jest.fn().mockResolvedValue([ + suggestionRow(42, 'something'), + suggestionRow(7, 'else') + ]); + await cmd.autoCompleteSuggestionID(interaction); + const choices = interaction.respond.mock.calls[0][0]; + expect(choices.map(c => c.value)).toEqual(['42']); + }); + + test('caps results at 25 entries', async () => { + const interaction = makeInteraction(); + interaction.value = ''; + const rows = Array.from({length: 40}, (_, i) => suggestionRow(i + 1, 'idea ' + i)); + interaction.client.models.suggestions.Suggestion.findAll = jest.fn().mockResolvedValue(rows); + await cmd.autoCompleteSuggestionID(interaction); + expect(interaction.respond.mock.calls[0][0]).toHaveLength(25); + }); +}); \ No newline at end of file diff --git a/tests/suggestions/models.test.js b/tests/suggestions/models.test.js new file mode 100644 index 00000000..2107e408 --- /dev/null +++ b/tests/suggestions/models.test.js @@ -0,0 +1,41 @@ +/* + * Schema test for the suggestions Suggestion model. + * + * sequelize is mocked so init() records the schema. We assert the auto-increment + * primary key, the JSON columns (comments / adminAnswer) the embed logic depends + * on, the table name and the loader config. + */ + +jest.mock('sequelize', () => { + const DataTypes = new Proxy({}, {get: (_t, prop) => ({__type: prop})}); + + class Model { + static init(attributes, options) { + this._attributes = attributes; + this._options = options; + return this; + } + } + + return { + DataTypes, + Model + }; +}); + +describe('suggestions Suggestion model', () => { + test('has an autoIncrement PK and JSON comment/answer columns', () => { + const mod = require('../../modules/suggestions/models/Suggestion'); + mod.init({}); + expect(mod._attributes.id.primaryKey).toBe(true); + expect(mod._attributes.id.autoIncrement).toBe(true); + expect(mod._attributes.comments.__type).toBe('JSON'); + expect(mod._attributes.adminAnswer.__type).toBe('JSON'); + expect(mod._options.tableName).toBe('suggestions_Suggestion'); + expect(mod._options.timestamps).toBe(true); + expect(mod.config).toEqual({ + name: 'Suggestion', + module: 'suggestions' + }); + }); +}); \ No newline at end of file diff --git a/tests/suggestions/suggestion.test.js b/tests/suggestions/suggestion.test.js new file mode 100644 index 00000000..94e3d6d2 --- /dev/null +++ b/tests/suggestions/suggestion.test.js @@ -0,0 +1,263 @@ +/* + * Behavior tests for the suggestions core module (suggestion.js). + * + * Covers the parts with real branching/transition logic: + * - generateSuggestionEmbed(): picks the right config field + * (unanswered / approved / denied) based on suggestion.adminAnswer and edits + * the suggestion message accordingly; bails out if the message is gone + * - notifyMembers(): respects the sendPNNotifications switch, builds the + * subscriber set (suggester + admin answerer, de-duplicated) and skips the + * ignored user + * - createSuggestion(): pings the notify role, reacts, optionally opens a + * thread, persists the row and renders the embed + * + * embedType/formatDiscordUserName are mocked so we assert which config field and + * params were used, not the embed renderer itself. + */ + +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn((field, params) => ({ + field, + params + })), + formatDiscordUserName: (u) => (u && u.tag) || 'unknown' +})); + +const helpers = require('../../src/functions/helpers'); +const { + generateSuggestionEmbed, + notifyMembers, + createSuggestion +} = require('../../modules/suggestions/suggestion'); + +const moduleConfig = { + suggestionChannel: 'sugg-chan', + sendPNNotifications: true, + notifyRole: '', + allowUserComment: false, + reactions: [], + threadName: 'Comments', + unansweredSuggestion: 'UNANSWERED', + approvedSuggestion: 'APPROVED', + deniedSuggestion: 'DENIED', + teamChange: 'TEAMCHANGE' +}; + +function makeClient({ + message = {edit: jest.fn().mockResolvedValue()}, + config = moduleConfig + } = {}) { + return { + guild: {id: 'g1'}, + configurations: {suggestions: {config}}, + channels: { + fetch: jest.fn().mockResolvedValue({ + messages: {fetch: jest.fn().mockResolvedValue(message)} + }) + }, + users: { + fetch: jest.fn().mockResolvedValue({ + avatarURL: () => 'a', + tag: 'U#1', + send: jest.fn().mockResolvedValue() + }) + } + }; +} + +beforeEach(() => helpers.embedType.mockClear()); + +describe('generateSuggestionEmbed', () => { + test('uses the unanswered field when there is no admin answer', async () => { + const message = {edit: jest.fn().mockResolvedValue()}; + const client = makeClient({message}); + await generateSuggestionEmbed(client, { + id: 1, + suggestion: 's', + messageID: 'm', + suggesterID: 'u', + adminAnswer: null + }); + expect(helpers.embedType).toHaveBeenCalledWith('UNANSWERED', expect.any(Object)); + expect(message.edit).toHaveBeenCalled(); + }); + + test('uses the approved field when the admin approved', async () => { + const client = makeClient(); + await generateSuggestionEmbed(client, { + id: 1, + suggestion: 's', + messageID: 'm', + suggesterID: 'u', + adminAnswer: { + action: 'approve', + reason: 'ok', + userID: 'admin' + } + }); + expect(helpers.embedType).toHaveBeenCalledWith('APPROVED', expect.objectContaining({ + '%adminUser%': '<@admin>', + '%adminMessage%': 'ok' + })); + }); + + test('uses the denied field for any non-approve action', async () => { + const client = makeClient(); + await generateSuggestionEmbed(client, { + id: 1, + suggestion: 's', + messageID: 'm', + suggesterID: 'u', + adminAnswer: { + action: 'deny', + reason: 'no', + userID: 'admin' + } + }); + expect(helpers.embedType).toHaveBeenCalledWith('DENIED', expect.any(Object)); + }); + + test('does nothing if the suggestion message no longer exists', async () => { + const client = makeClient({message: null}); + await generateSuggestionEmbed(client, { + id: 1, + messageID: 'gone', + suggesterID: 'u', + adminAnswer: null + }); + expect(helpers.embedType).not.toHaveBeenCalled(); + }); +}); + +describe('notifyMembers', () => { + test('does nothing when DM notifications are disabled', async () => { + const client = makeClient({ + config: { + ...moduleConfig, + sendPNNotifications: false + } + }); + await notifyMembers(client, {suggesterID: 'u1'}, 'team'); + expect(client.users.fetch).not.toHaveBeenCalled(); + }); + + test('notifies the suggester and admin answerer, skipping the ignored user', async () => { + const sent = []; + const client = makeClient(); + client.users.fetch = jest.fn(async (id) => ({ + id, + send: jest.fn(async (m) => sent.push({ + id, + m + })) + })); + const suggestion = { + suggestion: 'title', + messageID: 'm1', + suggesterID: 'u1', + adminAnswer: {userID: 'admin1'} + }; + await notifyMembers(client, suggestion, 'team', 'admin1'); + // admin1 is the ignored user, so only u1 gets notified. + expect(sent.map(s => s.id)).toEqual(['u1']); + }); + + test('does not double-notify when the admin answerer equals the suggester', async () => { + const client = makeClient(); + const fetched = []; + client.users.fetch = jest.fn(async (id) => { + fetched.push(id); + return { + id, + send: jest.fn().mockResolvedValue() + }; + }); + await notifyMembers(client, { + suggestion: 't', + messageID: 'm', + suggesterID: 'u1', + adminAnswer: {userID: 'u1'} + }, 'team'); + expect(fetched).toEqual(['u1']); + }); +}); + +describe('createSuggestion', () => { + function makeGuild(config) { + const suggestionMsg = { + id: 'new-msg', + startThread: jest.fn().mockResolvedValue(), + react: jest.fn().mockResolvedValue() + }; + const channel = {send: jest.fn().mockResolvedValue(suggestionMsg)}; + const created = {id: 77}; + const client = { + guild: {id: 'g1'}, + configurations: {suggestions: {config}}, + channels: {fetch: jest.fn().mockResolvedValue({messages: {fetch: jest.fn().mockResolvedValue({edit: jest.fn().mockResolvedValue()})}})}, + users: { + fetch: jest.fn().mockResolvedValue({ + avatarURL: () => 'a', + tag: 'U#1' + }) + }, + models: {suggestions: {Suggestion: {create: jest.fn().mockResolvedValue(created)}}} + }; + const guild = { + client, + channels: {cache: {get: () => channel}} + }; + return { + guild, + channel, + suggestionMsg, + created, + client + }; + } + + test('persists the suggestion and renders the embed', async () => { + const { + guild, + channel, + created, + client + } = makeGuild(moduleConfig); + const result = await createSuggestion(guild, 'my idea', {id: 'author'}); + expect(channel.send).toHaveBeenCalled(); + expect(client.models.suggestions.Suggestion.create).toHaveBeenCalledWith(expect.objectContaining({ + suggestion: 'my idea', + messageID: 'new-msg', + suggesterID: 'author' + })); + expect(result).toBe(created); + }); + + test('pings the notify role when configured', async () => { + const { + guild, + channel + } = makeGuild({ + ...moduleConfig, + notifyRole: 'role9' + }); + await createSuggestion(guild, 'idea', {id: 'author'}); + expect(channel.send.mock.calls[0][0]).toContain('<@&role9>'); + }); + + test('opens a thread and applies reactions when enabled', async () => { + const { + guild, + suggestionMsg + } = makeGuild({ + ...moduleConfig, + allowUserComment: true, + threadName: 'Talk', + reactions: ['👍', '👎'] + }); + await createSuggestion(guild, 'idea', {id: 'author'}); + expect(suggestionMsg.startThread).toHaveBeenCalledWith({name: 'Talk'}); + expect(suggestionMsg.react).toHaveBeenCalledWith('👍'); + expect(suggestionMsg.react).toHaveBeenCalledWith('👎'); + }); +}); \ No newline at end of file diff --git a/tests/suggestions/suggestionCommandAndEvent.test.js b/tests/suggestions/suggestionCommandAndEvent.test.js new file mode 100644 index 00000000..b9dc83d5 --- /dev/null +++ b/tests/suggestions/suggestionCommandAndEvent.test.js @@ -0,0 +1,130 @@ +/* + * Tests for the two suggestion entry points the existing suite did not cover: + * + * - commands/suggestion.js run(): defers ephemerally, delegates to + * createSuggestion and echoes the configured success template with the new id + * - events/messageCreate.js run(): the guard chain (bot author, no guild, wrong + * guild, feature off, wrong channel) and the "channel suggestion" happy path + * that deletes the source message and creates a suggestion from its content + * + * The createSuggestion sibling and embedType helper are mocked. + */ + +jest.mock('../../modules/suggestions/suggestion', () => ({ + createSuggestion: jest.fn().mockResolvedValue({id: 123}) +})); +jest.mock('../../src/functions/helpers', () => ({ + embedType: jest.fn((tpl, params) => ({ + tpl, + params + })) +})); + +const {createSuggestion} = require('../../modules/suggestions/suggestion'); +const helpers = require('../../src/functions/helpers'); +const command = require('../../modules/suggestions/commands/suggestion'); +const event = require('../../modules/suggestions/events/messageCreate'); + +beforeEach(() => { + createSuggestion.mockClear(); + helpers.embedType.mockClear(); +}); + +describe('/suggestion command', () => { + test('defers ephemerally, creates the suggestion and confirms with its id', async () => { + const interaction = { + guild: {id: 'g1'}, + user: {id: 'u1'}, + options: {getString: jest.fn(() => 'add dark mode')}, + client: {configurations: {suggestions: {config: {successfullySubmitted: 'SUBMITTED'}}}}, + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue() + }; + await command.run(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ephemeral: true}); + expect(createSuggestion).toHaveBeenCalledWith(interaction.guild, 'add dark mode', interaction.user); + expect(helpers.embedType).toHaveBeenCalledWith('SUBMITTED', {'%id%': 123}); + expect(interaction.editReply).toHaveBeenCalled(); + }); +}); + +describe('suggestions messageCreate', () => { + function makeClient(overrides = {}) { + return { + config: {guildID: 'g1'}, + configurations: { + suggestions: { + config: { + createSuggestionFromMessagesInChannel: true, + suggestionChannel: 'sugg-chan', + ...overrides + } + } + } + }; + } + + function makeMsg(overrides = {}) { + return { + author: { + bot: false, + id: 'u1' + }, + guild: {id: 'g1'}, + channel: {id: 'sugg-chan'}, + cleanContent: 'please add X', + delete: jest.fn().mockResolvedValue(), + ...overrides + }; + } + + test('ignores bot authors', async () => { + const client = makeClient(); + const msg = makeMsg({ + author: { + bot: true, + id: 'b' + } + }); + await event.run(client, msg); + expect(msg.delete).not.toHaveBeenCalled(); + expect(createSuggestion).not.toHaveBeenCalled(); + }); + + test('ignores messages outside the configured guild', async () => { + const client = makeClient(); + const msg = makeMsg({guild: {id: 'other'}}); + await event.run(client, msg); + expect(createSuggestion).not.toHaveBeenCalled(); + }); + + test('ignores messages with no guild (DMs)', async () => { + const client = makeClient(); + const msg = makeMsg({guild: null}); + await event.run(client, msg); + expect(createSuggestion).not.toHaveBeenCalled(); + }); + + test('does nothing when channel-suggestions are disabled', async () => { + const client = makeClient({createSuggestionFromMessagesInChannel: false}); + const msg = makeMsg(); + await event.run(client, msg); + expect(msg.delete).not.toHaveBeenCalled(); + expect(createSuggestion).not.toHaveBeenCalled(); + }); + + test('ignores messages in a non-suggestion channel', async () => { + const client = makeClient(); + const msg = makeMsg({channel: {id: 'random'}}); + await event.run(client, msg); + expect(createSuggestion).not.toHaveBeenCalled(); + }); + + test('deletes the source message and creates a suggestion from its content', async () => { + const client = makeClient(); + const msg = makeMsg(); + await event.run(client, msg); + expect(msg.delete).toHaveBeenCalled(); + expect(createSuggestion).toHaveBeenCalledWith(msg.guild, 'please add X', msg.author); + }); +}); \ No newline at end of file diff --git a/tests/team-list/botReadyRun.test.js b/tests/team-list/botReadyRun.test.js new file mode 100644 index 00000000..10b9265e --- /dev/null +++ b/tests/team-list/botReadyRun.test.js @@ -0,0 +1,203 @@ +/* + * Behavior tests for the team-list botReady handler (events/botReady.js run() + * and its internal updateEmbedsIfNeeded). run() builds a per-channel role-roster + * embed and either edits an existing tracked message or sends a new one, then + * schedules periodic refreshes. + * + * Covers: scheduling + initial render, channel-not-found short circuit, the + * "no roles selected" warning field, sending a fresh message persists its id, + * editing an existing tracked message, and the is-equal dedup cache skipping a + * redundant edit on an unchanged embed. node-schedule is mocked so no real + * timers run; is-equal is mocked to a controllable comparator. + */ + +const mockScheduleJob = jest.fn(() => ({cancel: jest.fn()})); +jest.mock('node-schedule', () => ({scheduleJob: (...a) => mockScheduleJob(...a)})); + +let mockIsEqualReturn = false; +jest.mock('is-equal', () => (...args) => (typeof mockIsEqualReturn === 'function' ? mockIsEqualReturn(...args) : mockIsEqualReturn)); + +const botReady = require('../../modules/team-list/events/botReady'); + +function makeRole(id, name, position) { + return { + id, + name, + position, + toString: () => `<@&${id}>` + }; +} + +function collection(items) { + const map = new Map(items.map(i => [i.id, i])); + map.filter = (fn) => collection([...map.values()].filter(fn)); + map.sort = (cmp) => collection([...map.values()].sort(cmp)); + return map; +} + +function makeMember(id, roleIds) { + return { + user: { + id, + toString: () => `<@${id}>` + }, + presence: {status: 'online'}, + roles: {cache: {has: (rid) => roleIds.includes(rid)}} + }; +} + +function makeClient({ + channels = [], + roles = [], + members = [], + channelFound = true, + existingMessageID = null + } = {}) { + const sentMessages = []; + const editedMessages = []; + const messageData = { + messageID: existingMessageID, + save: jest.fn().mockResolvedValue() + }; + const channelObj = { + id: 'chan1', + guild: {roles: {fetch: jest.fn().mockResolvedValue(collection(roles))}}, + messages: { + fetch: jest.fn().mockResolvedValue(existingMessageID ? { + id: existingMessageID, + edit: jest.fn((m) => { + editedMessages.push(m); + return Promise.resolve(); + }) + } : null) + }, + send: jest.fn((m) => { + sentMessages.push(m); + return Promise.resolve({id: 'newmsg'}); + }) + }; + return { + _sent: sentMessages, + _edited: editedMessages, + _messageData: messageData, + configurations: {'team-list': {config: channels}}, + strings: { + footer: 'F', + footerImgUrl: 'http://i/f.png', + disableFooterTimestamp: false + }, + logger: {error: jest.fn()}, + jobs: [], + guild: {members: {cache: collection(members)}}, + channels: {fetch: jest.fn().mockResolvedValue(channelFound ? channelObj : null)}, + models: { + 'team-list': { + TeamListMessage: { + findOrCreate: jest.fn().mockResolvedValue([messageData]) + } + } + } + }; +} + +function baseChannelConfig(overrides = {}) { + return { + channelID: 'chan1', + roles: ['r1'], + nameOverwrites: {}, + descriptions: {}, + embed: { + color: 'BLUE', + title: 'Team' + }, + ...overrides + }; +} + +beforeEach(() => { + mockScheduleJob.mockClear(); + mockIsEqualReturn = false; +}); + +test('run schedules a cron refresh and pushes the job', async () => { + const client = makeClient({channels: []}); + await botReady.run(client); + expect(mockScheduleJob).toHaveBeenCalledWith('1,16,31,46 * * * *', expect.any(Function)); + expect(client.jobs.length).toBe(1); +}); + +test('logs and skips a channel that cannot be fetched', async () => { + const client = makeClient({ + channels: [baseChannelConfig()], + channelFound: false + }); + await botReady.run(client); + expect(client.logger.error).toHaveBeenCalledWith(expect.stringContaining('Could not find channel')); + expect(client.models['team-list'].TeamListMessage.findOrCreate).not.toHaveBeenCalled(); +}); + +test('sends a new message and persists its id when none is tracked yet', async () => { + const client = makeClient({ + channels: [baseChannelConfig()], + roles: [makeRole('r1', 'Mods', 5)], + members: [makeMember('u1', ['r1'])], + existingMessageID: null + }); + await botReady.run(client); + expect(client._sent.length).toBe(1); + expect(client._messageData.messageID).toBe('newmsg'); + expect(client._messageData.save).toHaveBeenCalled(); + const embed = client._sent[0].embeds[0].toJSON(); + expect(embed.fields[0].name).toBe('Mods'); + expect(embed.fields[0].value).toContain('<@u1>'); +}); + +test('applies nameOverwrites and role descriptions to the field', async () => { + const client = makeClient({ + channels: [baseChannelConfig({ + nameOverwrites: {r1: 'Custom'}, + descriptions: {r1: 'Desc line'} + })], + roles: [makeRole('r1', 'Mods', 5)], + members: [makeMember('u1', ['r1'])] + }); + await botReady.run(client); + const embed = client._sent[0].embeds[0].toJSON(); + expect(embed.fields[0].name).toBe('Custom'); + expect(embed.fields[0].value).toContain('Desc line'); +}); + +test('adds a warning field when no roles are selected', async () => { + const client = makeClient({ + channels: [baseChannelConfig({roles: []})], + roles: [makeRole('r1', 'Mods', 5)] + }); + await botReady.run(client); + const embed = client._sent[0].embeds[0].toJSON(); + expect(embed.fields[0].name).toBe('⚠️'); + expect(embed.fields[0].value).toBe('team-list.no-roles-selected'); +}); + +test('edits the existing tracked message instead of sending a new one', async () => { + const client = makeClient({ + channels: [baseChannelConfig()], + roles: [makeRole('r1', 'Mods', 5)], + members: [makeMember('u1', ['r1'])], + existingMessageID: 'old123' + }); + await botReady.run(client); + expect(client._edited.length).toBe(1); + expect(client._sent.length).toBe(0); +}); + +test('dedup cache skips the update when the embed is unchanged', async () => { + mockIsEqualReturn = true; // pretend lastSavedEmbed matches the freshly built embed + const client = makeClient({ + channels: [baseChannelConfig()], + roles: [makeRole('r1', 'Mods', 5)], + members: [makeMember('u1', ['r1'])] + }); + await botReady.run(client); + expect(client.models['team-list'].TeamListMessage.findOrCreate).not.toHaveBeenCalled(); + expect(client._sent.length).toBe(0); +}); \ No newline at end of file diff --git a/tests/team-list/buildUserString.test.js b/tests/team-list/buildUserString.test.js new file mode 100644 index 00000000..d5896b8b --- /dev/null +++ b/tests/team-list/buildUserString.test.js @@ -0,0 +1,76 @@ +/* + * Tests for the team-list per-role user-string builder. + * + * buildUserString was extracted (behavior-preserving) from updateEmbedsIfNeeded. + * It renders the members holding a role either as a status list (includeStatus) + * or a comma-separated mention list, drops the trailing ", " on the comma form, + * falls back to a "no users" localized string for empty roles, and (when + * onlineShowHighestRole is on) skips users already listed under a higher role - + * tracked via the mutated listedUserIDs accumulator. + */ +const {buildUserString} = require('../../modules/team-list/events/botReady').__test; + +const role = { + id: 'r1', + toString: () => '<@&r1>' +}; + +function member(id, status) { + return { + user: { + id, + toString: () => `<@${id}>` + }, + presence: status ? {status} : null + }; +} + +test('renders a comma-separated mention list and strips the trailing separator', () => { + const members = [member('a'), member('b')]; + const out = buildUserString(members, role, {includeStatus: false}, []); + expect(out).toBe('<@a>, <@b>'); +}); + +test('renders a status line per member when includeStatus is on', () => { + const members = [member('a', 'online'), member('b', 'dnd')]; + const out = buildUserString(members, role, {includeStatus: true}, []); + expect(out).toContain('<@a>: 🟢 team-list.online'); + expect(out).toContain('<@b>: 🔴 team-list.dnd'); +}); + +test('defaults a member without presence to the offline icon/label', () => { + const out = buildUserString([member('a')], role, {includeStatus: true}, []); + expect(out).toContain('⚫ team-list.offline'); +}); + +test('returns the localized empty-role string when no members hold the role', () => { + const out = buildUserString([], role, {includeStatus: false}, []); + expect(out).toBe('team-list.no-users-with-role(r=<@&r1>)'); +}); + +test('skips already-listed users when onlineShowHighestRole is enabled', () => { + const listed = ['a']; + const out = buildUserString([member('a'), member('b')], role, { + includeStatus: false, + onlineShowHighestRole: true + }, listed); + // 'a' was already listed under a higher role -> only 'b' appears + expect(out).toBe('<@b>'); + expect(listed).toEqual(['a', 'b']); +}); + +test('does NOT skip duplicates when onlineShowHighestRole is disabled', () => { + const listed = ['a']; + const out = buildUserString([member('a'), member('b')], role, { + includeStatus: false, + onlineShowHighestRole: false + }, listed); + expect(out).toBe('<@a>, <@b>'); +}); + +test('accumulates listed user ids across calls', () => { + const listed = []; + buildUserString([member('a')], role, {includeStatus: false}, listed); + buildUserString([member('b')], role, {includeStatus: false}, listed); + expect(listed).toEqual(['a', 'b']); +}); \ No newline at end of file diff --git a/tests/temp-channels/channelMode.test.js b/tests/temp-channels/channelMode.test.js new file mode 100644 index 00000000..9f66ca05 --- /dev/null +++ b/tests/temp-channels/channelMode.test.js @@ -0,0 +1,269 @@ +/* + * Behavior tests for temp-channels channel-settings.channelMode and channelEdit. + * + * channelMode flips a temp voice channel between public and private, reconfiguring + * permission overwrites for @everyone / the bot / the creator / allowed users / + * privateBypassRoles, and persists the new isPublic flag. channelEdit validates and + * applies user-limit / bitrate / name / nsfw changes from either the slash command + * or the edit modal, rejecting out-of-range values and reporting "nothing changed". + * The DB client comes from ../../main (jest-mapped stub) which we mutate per test; + * embedType runs for real. + */ +const mainStub = require('../__stubs__/main'); +const settings = require('../../modules/temp-channels/channel-settings'); + +function setVC(vc) { + mainStub.client.models = {'temp-channels': {TempChannel: {findOne: jest.fn().mockResolvedValue(vc)}}}; +} + +function makeVchann() { + return { + id: 'vc1', + nsfw: false, + bitrate: 64000, + userLimit: 0, + name: 'Old Name', + guild: {roles: {everyone: 'everyone-role'}}, + lockPermissions: jest.fn().mockResolvedValue(), + permissionOverwrites: {create: jest.fn().mockResolvedValue()}, + edit: jest.fn() + }; +} + +function makeInteraction({ + vc, + vchann, + config = {}, + membersCache = new Map(), + me = 'bot-me' + }) { + return { + client: { + configurations: { + 'temp-channels': { + config: { + modeSwitched: 'Mode now %mode%', + channelEdited: 'edited', + 'edit-error': 'edit-error', + ...config + } + } + } + }, + member: { + id: 'creator', + voice: {channelId: 'vc1'} + }, + guild: { + channels: {cache: {get: () => vchann}}, + members: { + me, + cache: membersCache + }, + maximumBitrate: 384000 + }, + options: { + getBoolean: jest.fn(), + getInteger: jest.fn(), + getString: jest.fn() + }, + fields: { + getTextInputValue: jest.fn(), + getStringSelectValues: jest.fn() + }, + editReply: jest.fn().mockResolvedValue() + }; +} + +describe('channelMode', () => { + test('public: locks perms, grants the bot manage rights, saves isPublic=true', async () => { + const vc = { + id: 'vc1', + allowedUsers: 'creator', + isPublic: false, + save: jest.fn().mockResolvedValue() + }; + setVC(vc); + const vchann = makeVchann(); + const interaction = makeInteraction({ + vc, + vchann + }); + interaction.options.getBoolean = jest.fn().mockReturnValue(true); + + await settings.channelMode(interaction, 'command'); + + expect(vchann.lockPermissions).toHaveBeenCalled(); + expect(vchann.permissionOverwrites.create).toHaveBeenCalledWith('bot-me', expect.objectContaining({ + CONNECT: true, + MANAGE_CHANNELS: true + })); + expect(vc.isPublic).toBe(true); + expect(vc.save).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'Mode now public'})); + }); + + test('buttonPublic caller forces public mode', async () => { + const vc = { + id: 'vc1', + allowedUsers: 'creator', + isPublic: false, + save: jest.fn().mockResolvedValue() + }; + setVC(vc); + const vchann = makeVchann(); + const interaction = makeInteraction({ + vc, + vchann + }); + await settings.channelMode(interaction, 'buttonPublic'); + expect(vchann.lockPermissions).toHaveBeenCalled(); + expect(vc.isPublic).toBe(true); + }); + + test('private: denies everyone, re-grants allowed users and bypass roles, saves isPublic=false', async () => { + const allowedMember = {id: 'friend'}; + const vc = { + id: 'vc1', + allowedUsers: 'creator,friend', + isPublic: true, + save: jest.fn().mockResolvedValue() + }; + setVC(vc); + const vchann = makeVchann(); + const membersCache = new Map([['creator', {id: 'creator'}], ['friend', allowedMember]]); + const interaction = makeInteraction({ + vc, + vchann, + membersCache, + config: {privateBypassRoles: ['mod-role']} + }); + interaction.options.getBoolean = jest.fn().mockReturnValue(false); + + await settings.channelMode(interaction, 'command'); + + // everyone denied + expect(vchann.permissionOverwrites.create).toHaveBeenCalledWith('everyone-role', { + CONNECT: false, + VIEW_CHANNEL: false + }); + // allowed user re-granted + expect(vchann.permissionOverwrites.create).toHaveBeenCalledWith(allowedMember, { + CONNECT: true, + VIEW_CHANNEL: true + }); + // bypass role granted + expect(vchann.permissionOverwrites.create).toHaveBeenCalledWith('mod-role', { + CONNECT: true, + VIEW_CHANNEL: true + }); + expect(vc.isPublic).toBe(false); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'Mode now private'})); + }); +}); + +describe('channelEdit (command)', () => { + test('applies name + user-limit changes and edits the channel', async () => { + const vc = {id: 'vc1'}; + setVC(vc); + const vchann = makeVchann(); + const interaction = makeInteraction({ + vc, + vchann + }); + interaction.options.getInteger = jest.fn((k) => k === 'user-limit' ? 5 : 0); + interaction.options.getString = jest.fn((k) => k === 'name' ? 'New Name' : null); + interaction.options.getBoolean = jest.fn().mockReturnValue(false); + + await settings.channelEdit(interaction, 'command'); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'edited'})); + expect(vchann.edit).toHaveBeenCalledWith(expect.objectContaining({ + userLimit: 5, + name: 'New Name' + })); + }); + + test('rejects an out-of-range bitrate with the edit-error message', async () => { + const vc = {id: 'vc1'}; + setVC(vc); + const vchann = makeVchann(); + const interaction = makeInteraction({ + vc, + vchann + }); + // user-limit defaulted (-1 so the >=0 guard is false), bitrate too low (<=8000) + interaction.options.getInteger = jest.fn((k) => k === 'bitrate' ? 8000 : -1); + interaction.options.getString = jest.fn().mockReturnValue(null); + interaction.options.getBoolean = jest.fn().mockReturnValue(false); + + await settings.channelEdit(interaction, 'command'); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'edit-error'})); + expect(vchann.edit).not.toHaveBeenCalled(); + }); + + test('reports nothing-changed when no option was provided', async () => { + const vc = {id: 'vc1'}; + setVC(vc); + const vchann = makeVchann(); + const interaction = makeInteraction({ + vc, + vchann + }); + // user-limit negative (skips the >=0 branch), bitrate null (falsy), no name, nsfw false + interaction.options.getInteger = jest.fn((k) => k === 'user-limit' ? -1 : null); + interaction.options.getString = jest.fn().mockReturnValue(null); + interaction.options.getBoolean = jest.fn().mockReturnValue(false); + + await settings.channelEdit(interaction, 'command'); + + expect(interaction.editReply).toHaveBeenCalledWith('temp-channels.nothing-changed'); + expect(vchann.edit).not.toHaveBeenCalled(); + }); +}); + +describe('channelEdit (modal)', () => { + test('rejects a non-numeric limit input', async () => { + const vc = {id: 'vc1'}; + setVC(vc); + const vchann = makeVchann(); + const interaction = makeInteraction({ + vc, + vchann + }); + interaction.fields.getTextInputValue = jest.fn((k) => k === 'edit-modal-limit-input' ? 'abc' : 'X'); + + await settings.channelEdit(interaction, 'modal'); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'edit-error'})); + expect(vchann.edit).not.toHaveBeenCalled(); + }); + + test('applies modal values (limit, bitrate, name, nsfw)', async () => { + const vc = {id: 'vc1'}; + setVC(vc); + const vchann = makeVchann(); + const interaction = makeInteraction({ + vc, + vchann + }); + interaction.fields.getTextInputValue = jest.fn((k) => { + if (k === 'edit-modal-limit-input') return '10'; + if (k === 'edit-modal-name-input') return 'Modal Name'; + return ''; + }); + interaction.fields.getStringSelectValues = jest.fn((k) => + k === 'edit-modal-bitrate-input' ? ['96000'] : ['true']); + + await settings.channelEdit(interaction, 'modal'); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'edited'})); + expect(vchann.edit).toHaveBeenCalledWith(expect.objectContaining({ + userLimit: '10', + bitrate: 96000, + name: 'Modal Name', + nsfw: true + })); + }); +}); \ No newline at end of file diff --git a/tests/temp-channels/channelSettings.test.js b/tests/temp-channels/channelSettings.test.js new file mode 100644 index 00000000..e5f558dd --- /dev/null +++ b/tests/temp-channels/channelSettings.test.js @@ -0,0 +1,276 @@ +/* + * Behavior tests for temp-channels channel-settings (userAdd / userRemove / usersList). + * + * These functions read/modify the comma-separated allowedUsers list on the + * TempChannel row and grant/revoke channel permissions accordingly. The module + * pulls the DB client from `../../main`, which the jest moduleNameMapper aliases + * to the test stub; we mutate that stub's client per test. + * + * Covered: deduplication when adding an already-allowed user, appending a new + * user (+ persisting), removing a user from the list and revoking access / + * disconnecting them, and the "no users" / not-in-channel branches of usersList. + */ +const mainStub = require('../__stubs__/main'); +const settings = require('../../modules/temp-channels/channel-settings'); + +function makeVchann(everyoneHasAccess = false) { + const perms = {has: () => everyoneHasAccess}; + return { + id: 'vc1', + guild: {roles: {everyone: 'everyone-role'}}, + permissionsFor: jest.fn().mockReturnValue(perms), + permissionOverwrites: { + create: jest.fn().mockResolvedValue(), + delete: jest.fn().mockResolvedValue() + } + }; +} + +function makeInteraction({ + vc, + vchann, + members = new Map() + }) { + return { + client: { + configurations: { + 'temp-channels': { + config: { + userAdded: 'temp-channels.userAdded', + userRemoved: 'temp-channels.userRemoved', + notInChannel: 'temp-channels.notInChannel', + listUsers: 'Allowed: %users%' + } + } + } + }, + member: { + id: 'creator', + voice: {channelId: 'vc1'} + }, + guild: { + channels: {cache: {get: () => vchann}}, + members: {cache: members} + }, + options: {getUser: jest.fn()}, + editReply: jest.fn().mockResolvedValue() + }; +} + +function setupClient(vc, users = {}) { + mainStub.client.models = {'temp-channels': {TempChannel: {findOne: jest.fn().mockResolvedValue(vc)}}}; + mainStub.client.users = {fetch: jest.fn(id => Promise.resolve(users[id] || null))}; +} + +describe('userAdd', () => { + test('appends a new user, persists, and replies with the added-user message', async () => { + const vc = { + id: 'vc1', + allowedUsers: 'creator', + isPublic: false, + save: jest.fn().mockResolvedValue() + }; + setupClient(vc); + const vchann = makeVchann(false); + const interaction = makeInteraction({ + vc, + vchann + }); + interaction.options.getUser = jest.fn().mockReturnValue({ + id: 'newuser', + username: 'New', + discriminator: '0', + tag: 'New#0' + }); + + await settings.userAdd(interaction, 'command'); + + expect(vc.allowedUsers).toBe('creator,newuser'); + expect(vc.save).toHaveBeenCalled(); + // everyone lacks access -> grant the new user explicit access + expect(vchann.permissionOverwrites.create).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalled(); + }); + + test('does not duplicate an already-allowed user', async () => { + const vc = { + id: 'vc1', + allowedUsers: 'creator,existing', + isPublic: false, + save: jest.fn().mockResolvedValue() + }; + setupClient(vc); + const vchann = makeVchann(false); + const interaction = makeInteraction({ + vc, + vchann + }); + interaction.options.getUser = jest.fn().mockReturnValue({ + id: 'existing', + username: 'Ex', + discriminator: '0', + tag: 'Ex#0' + }); + + await settings.userAdd(interaction, 'command'); + + expect(vc.allowedUsers).toBe('creator,existing'); // unchanged + expect(vc.save).not.toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalled(); + }); + + test('does not grant an explicit overwrite when the channel is already public to everyone', async () => { + const vc = { + id: 'vc1', + allowedUsers: 'creator', + isPublic: true, + save: jest.fn().mockResolvedValue() + }; + setupClient(vc); + const vchann = makeVchann(true); // everyone already has CONNECT + VIEW + const interaction = makeInteraction({ + vc, + vchann + }); + interaction.options.getUser = jest.fn().mockReturnValue({ + id: 'newuser', + username: 'New', + discriminator: '0', + tag: 'New#0' + }); + + await settings.userAdd(interaction, 'command'); + + expect(vc.allowedUsers).toBe('creator,newuser'); + expect(vchann.permissionOverwrites.create).not.toHaveBeenCalled(); + }); +}); + +describe('userRemove', () => { + test('removes the user from the list and revokes access on a private channel', async () => { + const vc = { + id: 'vc1', + allowedUsers: 'creator,target', + isPublic: false, + save: jest.fn().mockResolvedValue() + }; + setupClient(vc); + const vchann = makeVchann(false); + const removedUser = { + id: 'target', + username: 'T', + discriminator: '0', + tag: 'T#0' + }; + const members = new Map([['target', { + voice: { + channelId: 'other', + disconnect: jest.fn() + } + }]]); + const interaction = makeInteraction({ + vc, + vchann, + members + }); + interaction.options.getUser = jest.fn().mockReturnValue(removedUser); + + await settings.userRemove(interaction, 'command'); + + expect(vc.allowedUsers).toBe('creator'); + expect(vc.save).toHaveBeenCalled(); + // private channel -> deny via create, not delete + expect(vchann.permissionOverwrites.create).toHaveBeenCalledWith(removedUser, { + CONNECT: false, + VIEW_CHANNEL: false + }); + expect(interaction.editReply).toHaveBeenCalled(); + }); + + test('deletes the overwrite (rather than denying) on a public channel and disconnects an in-channel member', async () => { + const vc = { + id: 'vc1', + allowedUsers: 'creator,target', + isPublic: true, + save: jest.fn().mockResolvedValue() + }; + setupClient(vc); + const vchann = makeVchann(false); + const removedUser = { + id: 'target', + username: 'T', + discriminator: '0', + tag: 'T#0' + }; + const disconnect = jest.fn().mockResolvedValue(); + const members = new Map([['target', { + voice: { + channelId: 'vc1', + disconnect + } + }]]); + const interaction = makeInteraction({ + vc, + vchann, + members + }); + interaction.options.getUser = jest.fn().mockReturnValue(removedUser); + + await settings.userRemove(interaction, 'command'); + + expect(vchann.permissionOverwrites.delete).toHaveBeenCalledWith(removedUser); + // member sits in the temp channel -> disconnected + expect(disconnect).toHaveBeenCalled(); + }); +}); + +describe('usersList', () => { + test('replies with notInChannel when the caller does not own a temp channel', async () => { + setupClient(null); + const interaction = makeInteraction({ + vc: null, + vchann: makeVchann() + }); + + await settings.usersList(interaction); + + const arg = interaction.editReply.mock.calls[0][0]; + expect(JSON.stringify(arg)).toContain('notInChannel'); + }); + + test('replies with a no-added-user notice when the list is empty', async () => { + const vc = { + id: 'vc1', + allowedUsers: '' + }; + setupClient(vc); + const interaction = makeInteraction({ + vc, + vchann: makeVchann() + }); + + await settings.usersList(interaction); + + const arg = interaction.editReply.mock.calls[0][0]; + expect(JSON.stringify(arg)).toContain('no-added-user'); + }); + + test('lists allowed users as mentions when present', async () => { + const vc = { + id: 'vc1', + allowedUsers: 'aaa,bbb' + }; + setupClient(vc); + const interaction = makeInteraction({ + vc, + vchann: makeVchann() + }); + + await settings.usersList(interaction); + + const arg = interaction.editReply.mock.calls[0][0]; + const text = typeof arg === 'string' ? arg : JSON.stringify(arg); + expect(text).toContain('<@aaa>'); + expect(text).toContain('<@bbb>'); + }); +}); \ No newline at end of file diff --git a/tests/temp-channels/eventsAndCommand.test.js b/tests/temp-channels/eventsAndCommand.test.js new file mode 100644 index 00000000..b0182cc4 --- /dev/null +++ b/tests/temp-channels/eventsAndCommand.test.js @@ -0,0 +1,280 @@ +/* + * Behavior tests for the temp-channels settings-message sender, the channelDelete + * cleanup event, the slash command's beforeSubcommand gate / option builder, and the + * interactionCreate button/modal/select router. + * + * - sendMessage: builds the two-row settings button panel and either edits the + * tracked settings message or sends + persists a new one. + * - channelDelete: when a deleted channel maps to a TempChannel row it also deletes + * the partner (no-mic/main) channel and destroys the row; ignores unrelated channels. + * - command.beforeSubcommand: defers, sets interaction.cancel based on whether the + * caller owns the temp channel they're in. + * - interactionCreate: every button/modal/select branch replies notInChannel when the + * caller has no owned temp channel, and otherwise routes to the right settings fn. + * + * The DB client is the jest-mapped ../../main stub; embedType runs for real. + */ +const mainStub = require('../__stubs__/main'); + +describe('sendMessage', () => { + const {sendMessage} = require('../../modules/temp-channels/channel-settings'); + + function setup({existingMessageID = null} = {}) { + const messageData = { + messageID: existingMessageID, + save: jest.fn().mockResolvedValue() + }; + mainStub.client.configurations = {'temp-channels': {config: {settingsMessage: 'Settings'}}}; + mainStub.client.models = { + 'temp-channels': { + SettingsMessage: { + findOrCreate: jest.fn().mockResolvedValue([messageData]) + } + } + }; + const editFn = jest.fn().mockResolvedValue(); + const channel = { + id: 'c1', + messages: {fetch: jest.fn().mockResolvedValue(existingMessageID ? {edit: editFn} : null)}, + send: jest.fn().mockResolvedValue({id: 'newmsg'}) + }; + return { + messageData, + channel, + editFn + }; + } + + test('sends a new panel and persists the message id when none exists', async () => { + const { + messageData, + channel + } = setup({existingMessageID: null}); + await sendMessage(channel); + expect(channel.send).toHaveBeenCalled(); + const payload = channel.send.mock.calls[0][0]; + // two action rows with the six settings buttons + expect(payload.components.length).toBe(2); + expect(messageData.messageID).toBe('newmsg'); + expect(messageData.save).toHaveBeenCalled(); + }); + + test('edits the existing settings message instead of sending', async () => { + const { + channel, + editFn + } = setup({existingMessageID: 'old'}); + await sendMessage(channel); + expect(editFn).toHaveBeenCalled(); + expect(channel.send).not.toHaveBeenCalled(); + }); +}); + +describe('channelDelete event', () => { + const handler = require('../../modules/temp-channels/events/channelDelete'); + + function makeClient(dbChannel, otherChannel) { + return { + botReadyAt: Date.now(), + models: {'temp-channels': {TempChannel: {findOne: jest.fn().mockResolvedValue(dbChannel)}}}, + channels: {fetch: jest.fn().mockResolvedValue(otherChannel)} + }; + } + + test('returns early when the bot is not ready', async () => { + const client = makeClient(null, null); + client.botReadyAt = null; + await handler.run(client, {id: 'c1'}); + expect(client.models['temp-channels'].TempChannel.findOne).not.toHaveBeenCalled(); + }); + + test('does nothing for a channel with no matching row', async () => { + const client = makeClient(null, null); + await handler.run(client, {id: 'c1'}); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + test('deletes the partner channel and destroys the row', async () => { + const partnerDelete = jest.fn().mockResolvedValue(); + const dbChannel = { + id: 'main', + noMicChannel: 'nomic', + destroy: jest.fn().mockResolvedValue() + }; + const client = makeClient(dbChannel, {delete: partnerDelete}); + await handler.run(client, {id: 'main'}); + // partner = noMicChannel id + expect(client.channels.fetch).toHaveBeenCalledWith('nomic'); + expect(partnerDelete).toHaveBeenCalled(); + expect(dbChannel.destroy).toHaveBeenCalled(); + }); + + test('destroys the row even when the partner channel cannot be fetched', async () => { + const dbChannel = { + id: 'main', + noMicChannel: null, + destroy: jest.fn().mockResolvedValue() + }; + const client = makeClient(dbChannel, undefined); + await handler.run(client, {id: 'main'}); + expect(dbChannel.destroy).toHaveBeenCalled(); + }); +}); + +describe('temp-channel command beforeSubcommand', () => { + const command = require('../../modules/temp-channels/commands/temp-channel'); + + function makeInteraction(vc) { + mainStub.client.models = {'temp-channels': {TempChannel: {findOne: jest.fn().mockResolvedValue(vc)}}}; + return { + deferReply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + member: { + id: 'creator', + voice: {channelId: 'vc1'} + }, + client: {configurations: {'temp-channels': {config: {notInChannel: 'not-in-channel'}}}} + }; + } + + test('defers and cancels when the caller owns no temp channel', async () => { + const interaction = makeInteraction(null); + await command.beforeSubcommand(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ephemeral: true}); + expect(interaction.cancel).toBe(true); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({content: 'not-in-channel'})); + }); + + test('allows the subcommand when the caller owns the channel', async () => { + const interaction = makeInteraction({id: 'vc1'}); + await command.beforeSubcommand(interaction); + expect(interaction.cancel).toBe(false); + expect(interaction.editReply).not.toHaveBeenCalled(); + }); + + test('config.options exposes mode/add/remove/list only when allowUserToChangeMode is on', () => { + mainStub.client.configurations = { + 'temp-channels': { + config: { + allowUserToChangeMode: true, + allowUserToChangeName: false + } + } + }; + const names = command.config.options().map(o => o.name); + expect(names).toEqual(expect.arrayContaining(['mode', 'add-user', 'remove-user', 'list-users'])); + expect(names).not.toContain('edit'); + }); + + test('config.options exposes edit only when allowUserToChangeName is on', () => { + mainStub.client.configurations = { + 'temp-channels': { + config: { + allowUserToChangeMode: false, + allowUserToChangeName: true + } + } + }; + const names = command.config.options().map(o => o.name); + expect(names).toEqual(['edit']); + }); + + test('subcommand handlers no-op when interaction.cancel is set', async () => { + const interaction = {cancel: true}; + // none of these should throw despite missing channel-settings dependencies + await command.subcommands.mode(interaction); + await command.subcommands['add-user'](interaction); + await command.subcommands['remove-user'](interaction); + await command.subcommands['list-users'](interaction); + await command.subcommands.edit(interaction); + }); +}); + +describe('interactionCreate router', () => { + const handler = require('../../modules/temp-channels/events/interactionCreate'); + + function baseInteraction(overrides = {}) { + return { + guild: { + id: 'g1', + channels: { + cache: { + get: () => ({ + id: 'vc1', + nsfw: false, + bitrate: 64000, + userLimit: 0, + name: 'n' + }) + } + }, + maximumBitrate: 384000 + }, + member: { + id: 'creator', + voice: {channelId: 'vc1'} + }, + client: { + botReadyAt: Date.now(), + config: {guildID: 'g1'}, + configurations: {'temp-channels': {config: {notInChannel: 'not-in-channel'}}}, + models: {'temp-channels': {TempChannel: {findOne: jest.fn()}}} + }, + isButton: () => false, + isModalSubmit: () => false, + isUserSelectMenu: () => false, + reply: jest.fn().mockResolvedValue(), + deferReply: jest.fn().mockResolvedValue(), + ...overrides + }; + } + + test('returns early before the bot is ready', async () => { + const interaction = baseInteraction(); + interaction.client.botReadyAt = null; + await handler.run(interaction.client, interaction); + expect(interaction.client.models['temp-channels'].TempChannel.findOne).not.toHaveBeenCalled(); + }); + + test('ignores interactions from a different guild', async () => { + const interaction = baseInteraction({ + guild: {id: 'other'}, + isButton: () => true + }); + await handler.run(interaction.client, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + }); + + test('button tempc-add replies notInChannel when caller owns no channel', async () => { + const interaction = baseInteraction({ + isButton: () => true, + customId: 'tempc-add' + }); + interaction.client.models['temp-channels'].TempChannel.findOne.mockResolvedValue(null); + await handler.run(interaction.client, interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'not-in-channel'})); + }); + + test('button tempc-add opens a user-select when caller owns the channel', async () => { + const interaction = baseInteraction({ + isButton: () => true, + customId: 'tempc-add' + }); + interaction.client.models['temp-channels'].TempChannel.findOne.mockResolvedValue({id: 'vc1'}); + await handler.run(interaction.client, interaction); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.ephemeral).toBe(true); + expect(arg.components.length).toBe(1); + }); + + test('user-select with no owned channel replies notInChannel', async () => { + const interaction = baseInteraction({ + isUserSelectMenu: () => true, + customId: 'tempc-add-select' + }); + interaction.client.models['temp-channels'].TempChannel.findOne.mockResolvedValue(null); + await handler.run(interaction.client, interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'not-in-channel'})); + expect(interaction.deferReply).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/tic-tak-toe/run.test.js b/tests/tic-tak-toe/run.test.js new file mode 100644 index 00000000..ce252e98 --- /dev/null +++ b/tests/tic-tak-toe/run.test.js @@ -0,0 +1,251 @@ +/* + * Behavior tests for the tic-tac-toe command run() — the interactive game loop + * built on a message-component collector. Existing tests cover the pure win/draw + * detectors; this drives the collector handlers to exercise: the self-invite + * guard, the challenge message, invite-accept vs invite-deny, turn enforcement + * (only the invited player can accept, only the current player can move), a full + * win line ending with a win-header update, and the collector "end" (timeout) + * editing the message with the expiry reason. + * + * We fake interaction.reply({fetchReply}) -> a message exposing a collector whose + * registered 'collect'/'end' handlers we invoke directly. + */ +// Force the random starting-player pick to be deterministic: always the first +// element, which run() passes as [interaction.member, member] -> the inviter starts. +jest.mock('../../src/functions/helpers', () => { + const actual = jest.requireActual('../../src/functions/helpers'); + return { + ...actual, + randomElementFromArray: (arr) => arr[0] + }; +}); + +const command = require('../../modules/tic-tak-toe/commands/tic-tac-toe'); + +// run() arms a real 120s invite-expiry setTimeout; fake timers keep it from leaking +// past the test and triggering Jest's "worker failed to exit" teardown warning. +beforeEach(() => jest.useFakeTimers()); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +function makeCollector() { + const handlers = {}; + return { + ended: false, + on(event, fn) { + handlers[event] = fn; + return this; + }, + stop() { + this.ended = true; + if (handlers.end) handlers.end(); + }, + emitCollect(i) { + return handlers.collect(i); + }, + emitEnd() { + if (handlers.end) handlers.end(); + } + }; +} + +function makeMember(id) { + return { + id, + user: { + id, + bot: false + }, + toString: () => `<@${id}>` + }; +} + +function makeRunInteraction({ + inviterId = 'inviter', + inviteeId = 'invitee' + } = {}) { + const collector = makeCollector(); + const repEdit = jest.fn().mockResolvedValue(); + const rep = { + createMessageComponentCollector: jest.fn().mockReturnValue(collector), + edit: repEdit + }; + const invitee = makeMember(inviteeId); + const inviter = makeMember(inviterId); + const interaction = { + user: { + id: inviterId, + toString: () => `<@${inviterId}>` + }, + member: inviter, + options: {getMember: jest.fn().mockReturnValue(invitee)}, + guild: {members: {cache: {filter: () => ({random: () => null})}}}, + reply: jest.fn().mockResolvedValue(rep) + }; + return { + interaction, + collector, + rep, + repEdit, + invitee, + inviter + }; +} + +// A click interaction on a board/invite button. +function click(userId, customId) { + return { + user: {id: userId}, + customId, + reply: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue() + }; +} + +test('rejects inviting yourself with an ephemeral warning', async () => { + const {interaction} = makeRunInteraction(); + interaction.options.getMember = jest.fn().mockReturnValue(makeMember('inviter')); // same as caller + await command.run(interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.content).toContain('tic-tac-toe.self-invite-not-possible'); +}); + +test('posts a challenge message with accept/deny buttons and a collector', async () => { + const { + interaction, + rep + } = makeRunInteraction(); + await command.run(interaction); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.content).toContain('tic-tac-toe.challenge-message'); + expect(arg.components[0].components.map(c => c.customId)).toEqual(['accept-invite', 'deny-invite']); + expect(rep.createMessageComponentCollector).toHaveBeenCalled(); +}); + +test('a non-invited user cannot accept the invite', async () => { + const { + interaction, + collector + } = makeRunInteraction(); + await command.run(interaction); + const i = click('stranger', 'accept-invite'); + await collector.emitCollect(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('tic-tac-toe.you-are-not-the-invited-one') + })); +}); + +test('denying the invite stops the collector and edits with the denied reason', async () => { + const { + interaction, + collector, + repEdit + } = makeRunInteraction(); + await command.run(interaction); + const i = click('invitee', 'deny-invite'); + await collector.emitCollect(i); + expect(collector.ended).toBe(true); + expect(repEdit).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('tic-tac-toe.invite-denied') + })); +}); + +test('accepting the invite renders the 3x3 board (no immediate end)', async () => { + const { + interaction, + collector + } = makeRunInteraction(); + await command.run(interaction); + const accept = click('invitee', 'accept-invite'); + await collector.emitCollect(accept); + expect(accept.update).toHaveBeenCalled(); + const payload = accept.update.mock.calls[0][0]; + // 3 rows of 3 buttons + expect(payload.components.length).toBe(3); + expect(payload.components[0].components.length).toBe(3); + expect(payload.content).toContain('tic-tac-toe.playing-header'); +}); + +test('the off-turn player (invitee, since inviter starts) cannot place a mark', async () => { + const { + interaction, + collector + } = makeRunInteraction(); + await command.run(interaction); + const accept = click('invitee', 'accept-invite'); + await collector.emitCollect(accept); + // Inviter is the deterministic starter -> invitee moving first is rejected. + const offTurn = click('invitee', '1-1'); + await collector.emitCollect(offTurn); + expect(offTurn.reply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('tic-tac-toe.not-your-turn') + })); + expect(offTurn.update).not.toHaveBeenCalled(); +}); + +test('a completed winning line ends the game with a win header', async () => { + const { + interaction, + collector + } = makeRunInteraction(); + await command.run(interaction); + const accept = click('invitee', 'accept-invite'); + await collector.emitCollect(accept); + + // Inviter starts; alternate inviter (top row) / invitee (middle row). + const seq = [ + ['inviter', '1-1'], ['invitee', '2-1'], + ['inviter', '1-2'], ['invitee', '2-2'], + ['inviter', '1-3'] // inviter completes the top row -> win + ]; + let lastClick; + for (const [who, cell] of seq) { + lastClick = click(who, cell); + await collector.emitCollect(lastClick); + } + const finalUpdate = lastClick.update.mock.calls[0][0]; + expect(finalUpdate.content).toContain('tic-tac-toe.win-header'); +}); + +test('filling the board without a line ends the game in a draw header', async () => { + const { + interaction, + collector + } = makeRunInteraction(); + await command.run(interaction); + const accept = click('invitee', 'accept-invite'); + await collector.emitCollect(accept); + // Inviter (X) and invitee (O) fill the board to a draw: + // X O X + // X O O + // O X X + const seq = [ + ['inviter', '1-1'], ['invitee', '1-2'], + ['inviter', '1-3'], ['invitee', '2-2'], + ['inviter', '2-1'], ['invitee', '2-3'], + ['inviter', '3-2'], ['invitee', '3-1'], + ['inviter', '3-3'] + ]; + let lastClick; + for (const [who, cell] of seq) { + lastClick = click(who, cell); + await collector.emitCollect(lastClick); + } + const finalUpdate = lastClick.update.mock.calls[0][0]; + expect(finalUpdate.content).toContain('tic-tac-toe.draw-header'); +}); + +test('collector end without a finished game edits the message with the expiry reason', async () => { + const { + interaction, + collector, + repEdit + } = makeRunInteraction(); + await command.run(interaction); + // never started -> endReason was set by the timeout; emulate timeout by stopping + collector.emitEnd(); + expect(repEdit).toHaveBeenCalledWith(expect.objectContaining({components: []})); +}); \ No newline at end of file diff --git a/tests/tic-tak-toe/winDetection.test.js b/tests/tic-tak-toe/winDetection.test.js new file mode 100644 index 00000000..2b7237f0 --- /dev/null +++ b/tests/tic-tak-toe/winDetection.test.js @@ -0,0 +1,149 @@ +/* + * Pure win/draw detection tests for tic-tac-toe. + * + * detectWin/isBoardFull were extracted (behavior-preserving) from the in-game + * checkGameEnded closure so the line-scan logic can be exercised directly: + * rows, columns, both diagonals, "no win", and full-board draw detection. + * The grid uses string row/col keys "1".."3" mapping to an owner id or null. + */ +const { + detectWin, + isBoardFull +} = require('../../modules/tic-tak-toe/commands/tic-tac-toe'); + +const A = 'playerA'; +const B = 'playerB'; + +function emptyGrid() { + return { + 1: { + 1: null, + 2: null, + 3: null + }, + 2: { + 1: null, + 2: null, + 3: null + }, + 3: { + 1: null, + 2: null, + 3: null + } + }; +} + +/** Build a grid from a 3x3 array of 'A' | 'B' | null. */ +function grid(rows) { + const map = { + A, + B + }; + const g = emptyGrid(); + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + const v = rows[r][c]; + g[r + 1][c + 1] = v === null ? null : map[v]; + } + } + return g; +} + +describe('detectWin', () => { + test('detects a top row win', () => { + const g = grid([ + ['A', 'A', 'A'], + [null, 'B', null], + ['B', null, null] + ]); + expect(detectWin(g, A)).toBe(true); + expect(detectWin(g, B)).toBe(false); + }); + + test('detects a middle column win', () => { + const g = grid([ + ['B', 'A', null], + [null, 'A', 'B'], + [null, 'A', null] + ]); + expect(detectWin(g, A)).toBe(true); + }); + + test('detects the main (top-left to bottom-right) diagonal', () => { + const g = grid([ + ['A', 'B', null], + ['B', 'A', null], + [null, null, 'A'] + ]); + expect(detectWin(g, A)).toBe(true); + }); + + test('detects the anti (top-right to bottom-left) diagonal', () => { + const g = grid([ + [null, 'B', 'A'], + ['B', 'A', null], + ['A', null, null] + ]); + expect(detectWin(g, A)).toBe(true); + }); + + test('returns false on an empty board', () => { + expect(detectWin(emptyGrid(), A)).toBe(false); + }); + + test('returns false for a board with no line', () => { + const g = grid([ + ['A', 'B', 'A'], + ['B', 'A', 'B'], + ['B', 'A', 'B'] + ]); + expect(detectWin(g, A)).toBe(false); + expect(detectWin(g, B)).toBe(false); + }); + + test('two non-adjacent same-owner cells are not a win', () => { + const g = grid([ + ['A', null, 'A'], + [null, null, null], + [null, null, null] + ]); + expect(detectWin(g, A)).toBe(false); + }); +}); + +describe('isBoardFull', () => { + test('false when at least one cell is empty', () => { + const g = grid([ + ['A', 'B', 'A'], + ['B', 'A', 'B'], + ['B', 'A', null] + ]); + expect(isBoardFull(g)).toBe(false); + }); + + test('true when every cell is filled', () => { + const g = grid([ + ['A', 'B', 'A'], + ['B', 'A', 'B'], + ['B', 'A', 'B'] + ]); + expect(isBoardFull(g)).toBe(true); + }); + + test('false for an empty board', () => { + expect(isBoardFull(emptyGrid())).toBe(false); + }); +}); + +describe('draw vs win interaction', () => { + test('a full board with a winning line is still a win for that player', () => { + const g = grid([ + ['A', 'A', 'A'], + ['B', 'B', 'A'], + ['B', 'A', 'B'] + ]); + expect(isBoardFull(g)).toBe(true); + expect(detectWin(g, A)).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/tickets/interactionCreate.test.js b/tests/tickets/interactionCreate.test.js new file mode 100644 index 00000000..6b5abedf --- /dev/null +++ b/tests/tickets/interactionCreate.test.js @@ -0,0 +1,130 @@ +/* + * Regression tests for the tickets button handler. + * + * The bug: creating a ticket performed several slow Discord API calls + * (channel create, message send, pin) BEFORE acknowledging the interaction. + * Discord requires acknowledgement within 3 seconds, so the token expired and + * replying afterwards threw "Unknown interaction" (10062). The fix is the + * acknowledge -> action -> confirm pattern: deferReply() first, editReply() last. + */ + +jest.mock('../../src/functions/localize', () => ({localize: (file, key) => `${file}.${key}`})); + +const mainStub = require('../__stubs__/main'); +const handler = require('../../modules/tickets/events/interactionCreate'); + +function makeElement() { + return { + name: 'Support', + ticketRoles: [], + 'ticket-create-category': 'cat1', + 'creation-message': 'Ticket %id% opened', + 'ticket-close-button': 'Close' + }; +} + +function makeClient() { + return { + botReadyAt: Date.now(), + config: { + guildID: 'g1', + disableEveryoneProtection: false, + timezone: 'UTC' + }, + configurations: {tickets: {config: [makeElement()]}}, + models: { + tickets: { + Ticket: { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ + id: 42, + save: jest.fn() + }) + } + } + }, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn() + } + }; +} + +function makeInteraction(customId) { + const msg = {pin: jest.fn().mockResolvedValue()}; + const channel = { + id: 'chan-new', + toString: () => '<#chan-new>', + send: jest.fn().mockResolvedValue(msg) + }; + return { + customId, + isButton: () => true, + user: { + id: 'u1', + tag: 'User#0001', + username: 'User', + discriminator: '0001', + toString: () => '<@u1>' + }, + member: {id: 'u1'}, + channel: { + id: 'panel-chan', + toString: () => '<#panel-chan>' + }, + guild: { + id: 'g1', + channels: { + create: jest.fn().mockResolvedValue(channel), + fetch: jest.fn().mockResolvedValue(null) + }, + roles: {cache: {find: () => ({id: 'everyone'})}} + }, + deferReply: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue(), + editReply: jest.fn().mockResolvedValue(), + createdChannel: channel + }; +} + +beforeEach(() => { + mainStub.client.config = { + disableEveryoneProtection: false, + timezone: 'UTC' + }; + mainStub.client.strings = { + footer: 'f', + footerImgUrl: '', + disableFooterTimestamp: false, + addAtToUsernames: false + }; + mainStub.client.scnxSetup = false; +}); + +describe('tickets create-ticket interaction', () => { + test('acknowledges the interaction before doing slow Discord work', async () => { + const client = makeClient(); + const interaction = makeInteraction('create-ticket-0'); + + await handler.run(client, interaction); + + expect(interaction.deferReply).toHaveBeenCalledTimes(1); + // Acknowledge BEFORE the slow channel creation / message send. + const deferOrder = interaction.deferReply.mock.invocationCallOrder[0]; + expect(interaction.guild.channels.create.mock.invocationCallOrder[0]).toBeGreaterThan(deferOrder); + expect(interaction.createdChannel.send.mock.invocationCallOrder[0]).toBeGreaterThan(deferOrder); + }); + + test('confirms with editReply (not reply) after the ticket is created', async () => { + const client = makeClient(); + const interaction = makeInteraction('create-ticket-0'); + + await handler.run(client, interaction); + + expect(interaction.editReply).toHaveBeenCalledTimes(1); + // reply() on an already-acknowledged interaction throws "already acknowledged". + expect(interaction.reply).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/twitch-notifications/classifyStreamUpdate.test.js b/tests/twitch-notifications/classifyStreamUpdate.test.js new file mode 100644 index 00000000..6eba06d5 --- /dev/null +++ b/tests/twitch-notifications/classifyStreamUpdate.test.js @@ -0,0 +1,49 @@ +/* + * Tests for the twitch-notifications poll classifier. + * + * classifyStreamUpdate was extracted (behavior-preserving) from the `start` + * branch ladder. It maps (stream, persistedStreamer) to the action the poller + * takes: user not found, a brand new live stream, a re-live (different start + * time than what we stored), going offline, or no change (same stream as last + * poll). This is the dedup heart of the module - the same stream must not + * re-announce. + */ +// @twurple packages are ESM-only and only used inside run(); stub them so the +// module loads under CommonJS jest. +jest.mock('@twurple/api', () => ({ + ApiClient: class { + } +}), {virtual: true}); +jest.mock('@twurple/auth', () => ({ + AppTokenAuthProvider: class { + } +}), {virtual: true}); + +const {classifyStreamUpdate} = require('../../modules/twitch-notifications/events/botReady').__test; + +const stream = (startDate) => ({ + startDate: {toString: () => startDate}, + userDisplayName: 'Streamer' +}); + +test('returns userNotFound for the sentinel string', () => { + expect(classifyStreamUpdate('userNotFound', null)).toBe('userNotFound'); + expect(classifyStreamUpdate('userNotFound', {startedAt: 'x'})).toBe('userNotFound'); +}); + +test('returns newLive when live but no row is stored yet', () => { + expect(classifyStreamUpdate(stream('2024-01-01'), null)).toBe('newLive'); +}); + +test('returns reLive when the stored start time differs from the current stream', () => { + expect(classifyStreamUpdate(stream('2024-01-02'), {startedAt: '2024-01-01'})).toBe('reLive'); +}); + +test('returns noChange when the stream start time matches the stored one (dedup)', () => { + expect(classifyStreamUpdate(stream('2024-01-01'), {startedAt: '2024-01-01'})).toBe('noChange'); +}); + +test('returns offline when the stream is null', () => { + expect(classifyStreamUpdate(null, {startedAt: '2024-01-01'})).toBe('offline'); + expect(classifyStreamUpdate(null, null)).toBe('offline'); +}); \ No newline at end of file diff --git a/tests/uno/gameRules.test.js b/tests/uno/gameRules.test.js new file mode 100644 index 00000000..1a8c2854 --- /dev/null +++ b/tests/uno/gameRules.test.js @@ -0,0 +1,253 @@ +/* + * Pure game-rule tests for UNO. + * + * canUseCard decides whether a card may be played on top of game.lastCard, + * factoring in: color/number match, wilds (color / colordraw4), and the + * pending-draw stacking rule (while draws are pending you may only respond + * with a draw2 / draw4). nextPlayer rotates the turn flag respecting play + * direction (reversed) and the 2-player reverse-acts-as-skip special case. + * + * Card name constants come from the localize stub, so e.g. the wild is + * "uno.color" and the +4 is "uno.colordraw4". + */ +const {__test} = require('../../modules/uno/commands/uno'); +const { + canUseCard, + nextPlayer, + colors +} = __test; + +const WILD = 'uno.color'; +const WILD4 = 'uno.colordraw4'; +const DRAW2 = 'uno.draw2'; + +const game = (lastCard, pendingDraws = 0) => ({ + lastCard, + pendingDraws, + reversed: false, + inactiveTimeout: [], + players: [], + msg: { + channel: {send: jest.fn()}, + id: 'm', + edit: jest.fn() + } +}); + +beforeEach(() => jest.useFakeTimers()); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +describe('canUseCard - basic matching', () => { + const g = game({ + name: '5', + color: 'red' + }); + + test('matches by identical color', () => { + expect(canUseCard(g, { + name: '9', + color: 'red' + }, [])).toBe(true); + }); + + test('matches by identical number/name', () => { + expect(canUseCard(g, { + name: '5', + color: 'blue' + }, [])).toBe(true); + }); + + test('rejects a card that matches neither color nor number', () => { + expect(canUseCard(g, { + name: '7', + color: 'blue' + }, [])).toBe(false); + }); +}); + +describe('canUseCard - wild cards', () => { + test('a plain wild (color) can always be played', () => { + const g = game({ + name: '5', + color: 'red' + }); + expect(canUseCard(g, { + name: WILD, + color: 'green' + }, [])).toBe(true); + }); + + test('a +4 is playable when the player holds no card of the current color', () => { + const g = game({ + name: '5', + color: 'red' + }); + const hand = [{ + name: '2', + color: 'blue' + }, { + name: WILD4, + color: 'green' + }]; + expect(canUseCard(g, { + name: WILD4, + color: 'green' + }, hand)).toBe(true); + }); + + test('a +4 is NOT auto-true when the player holds a matching-color card (falls back to color/name match)', () => { + const g = game({ + name: '5', + color: 'red' + }); + // hand contains a red card, so the "true" shortcut does not apply. + // The +4 card's own color is green which != red and name != 5, so false. + const hand = [{ + name: '8', + color: 'red' + }, { + name: WILD4, + color: 'green' + }]; + expect(canUseCard(g, { + name: WILD4, + color: 'green' + }, hand)).toBe(false); + }); +}); + +describe('canUseCard - pending draw stacking', () => { + test('while draws are pending, a normal card cannot be played', () => { + const g = game({ + name: '5', + color: 'red' + }, 2); + expect(canUseCard(g, { + name: '5', + color: 'red' + }, [])).toBe(false); + }); + + test('while draws are pending, a draw2 may be stacked', () => { + const g = game({ + name: DRAW2, + color: 'red' + }, 2); + expect(canUseCard(g, { + name: DRAW2, + color: 'blue' + }, [])).toBe(true); + }); + + test('while draws are pending on a non-draw2 last card, a +4 may be stacked', () => { + // The +4 wild shortcut requires lastCard not be a draw2; use a +4 as lastCard. + const g = game({ + name: WILD4, + color: 'red' + }, 4); + expect(canUseCard(g, { + name: WILD4, + color: 'green' + }, [])).toBe(true); + }); + + test('a +4 cannot be stacked directly onto a draw2 (implementation quirk)', () => { + const g = game({ + name: DRAW2, + color: 'red' + }, 2); + expect(canUseCard(g, { + name: WILD4, + color: 'green' + }, [])).toBe(false); + }); +}); + +describe('nextPlayer - turn rotation', () => { + function players(n) { + return Array.from({length: n}, (_, i) => ({ + id: 'p' + i, + n: i, + turn: i === 0, + uno: false + })); + } + + test('advances the turn to the next player in forward direction', () => { + const g = game({ + name: '5', + color: 'red' + }); + g.players = players(3); + nextPlayer(g, g.players[0]); + expect(g.players[0].turn).toBe(false); + expect(g.players[1].turn).toBe(true); + }); + + test('wraps around to the first player past the end', () => { + const g = game({ + name: '5', + color: 'red' + }); + g.players = players(3); + g.players[2].turn = true; + g.players[0].turn = false; + nextPlayer(g, g.players[2]); + expect(g.players[0].turn).toBe(true); + }); + + test('moves backward when the game is reversed', () => { + const g = game({ + name: '5', + color: 'red' + }); + g.reversed = true; + g.players = players(3); + g.players[2].turn = true; + g.players[0].turn = false; + nextPlayer(g, g.players[2]); + expect(g.players[1].turn).toBe(true); + }); + + test('a "skip" (moves=2) jumps over the next player', () => { + const g = game({ + name: '5', + color: 'red' + }); + g.players = players(3); + nextPlayer(g, g.players[0], 2, true); + expect(g.players[2].turn).toBe(true); + expect(g.players[1].turn).toBe(false); + }); + + test('in a 2-player game, reverse-as-skip keeps the same player on turn', () => { + const g = game({ + name: '5', + color: 'red' + }); + g.players = players(2); + nextPlayer(g, g.players[0], 1, true); + expect(g.players[0].turn).toBe(true); + expect(g.players[1].turn).toBe(false); + }); + + test('clears the previous turn-holder uno flag on the new player', () => { + const g = game({ + name: '5', + color: 'red' + }); + g.players = players(2); + g.players[1].uno = true; + nextPlayer(g, g.players[0]); + expect(g.players[1].uno).toBe(false); + }); +}); + +describe('uno deck constants', () => { + test('exposes the four standard colors', () => { + expect(colors.sort()).toEqual(['blue', 'green', 'red', 'yellow']); + }); +}); \ No newline at end of file diff --git a/tests/uno/gameplay.test.js b/tests/uno/gameplay.test.js new file mode 100644 index 00000000..792de0d6 --- /dev/null +++ b/tests/uno/gameplay.test.js @@ -0,0 +1,462 @@ +/* + * Gameplay tests for UNO, complementing the pure-rule tests in gameRules.test.js. + * + * Covers gameMsg (player roster + turn line + pending-draws warning), buildDeck + * (per-card playability styling / disabling and the draw vs update control button), + * perPlayerHandler core branches (off-turn guard, the uno-update refresh, drawing a + * card, playing a valid/invalid card, winning by emptying the hand, the "missing + * uno" penalty, reverse flipping direction, choosing a wild colour), nextPlayer's + * inactivity timers, and the run() lobby (join / not-host / host-start / uno button). + * + * Localize stub yields "." so card-name constants are e.g. "uno.skip". + */ +const uno = require('../../modules/uno/commands/uno'); +const { + gameMsg, + buildDeck, + perPlayerHandler, + nextPlayer, + colorEmojis +} = uno.__test; + +const REVERSE = 'uno.reverse'; +const SKIP = 'uno.skip'; +const WILD = 'uno.color'; + +beforeEach(() => jest.useFakeTimers()); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +function makePlayer(id, n, cards, extra = {}) { + return { + id, + n, + cards, + uno: false, + turn: false, + blockRedraw: false, ...extra + }; +} + +function makeGame(players, lastCard = { + name: '5', + color: 'red' +}, extra = {}) { + return { + players, + lastCard, + previousCards: [], + inactiveTimeout: [], + turns: 0, + reversed: false, + justChoosingColor: false, + pendingDraws: 0, + msg: { + id: 'm', + channel: { + id: 'c', + send: jest.fn() + }, + edit: jest.fn().mockResolvedValue() + }, + ...extra + }; +} + +function clickInteraction(customId, userId = 'p0') { + return { + customId, + user: {id: userId}, + update: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue(), + deferUpdate: jest.fn().mockResolvedValue(), + followUp: jest.fn().mockResolvedValue({ + createMessageComponentCollector: () => ({ + on: () => { + } + }) + }) + }; +} + +describe('gameMsg', () => { + test('lists each player card count and the current turn, mentioning the turn-holder', () => { + const players = [makePlayer('p0', 0, [{}, {}], {turn: true}), makePlayer('p1', 1, [{}])]; + const game = makeGame(players); + const out = gameMsg(game); + expect(out.content).toContain('<@p0>'); + expect(out.content).toContain('uno.turn'); + expect(out.allowedMentions.users).toEqual(['p0']); + expect(colorEmojis[game.lastCard.color]).toBeDefined(); + expect(out.content).toContain(game.lastCard.name); + }); + + test('appends a pending-draws warning when draws are stacked', () => { + const players = [makePlayer('p0', 0, [{}], {turn: true})]; + const game = makeGame(players, { + name: 'uno.draw2', + color: 'red' + }, {pendingDraws: 4}); + expect(gameMsg(game).content).toContain('uno.pending-draws'); + }); + + test('shows an empty hand as 7 cards (lobby placeholder)', () => { + const players = [makePlayer('p0', 0, [], {turn: true})]; + expect(gameMsg(makeGame(players)).content).toContain('**7**'); + }); +}); + +describe('buildDeck', () => { + test('turn player with playable card gets a draw button and an enabled card', () => { + const player = makePlayer('p0', 0, [{ + name: '5', + color: 'blue' + }], {turn: true}); + const game = makeGame([player]); + const rows = buildDeck(player, game).map(r => r.toJSON()); + // control row first button = draw + expect(rows[0].components[0].custom_id).toBe('uno-draw'); + const cardBtn = rows[1].components[0]; + expect(cardBtn.disabled).toBe(false); + }); + + test('non-turn player gets an update button and all cards disabled', () => { + const player = makePlayer('p0', 0, [{ + name: '5', + color: 'blue' + }], {turn: false}); + const game = makeGame([player]); + const rows = buildDeck(player, game).map(r => r.toJSON()); + expect(rows[0].components[0].custom_id).toBe('uno-update'); + expect(rows[1].components[0].disabled).toBe(true); + }); + + test('neutral=true disables every card regardless of turn', () => { + const player = makePlayer('p0', 0, [{ + name: '5', + color: 'red' + }], {turn: true}); + const game = makeGame([player]); + const rows = buildDeck(player, game, true).map(r => r.toJSON()); + expect(rows[1].components[0].disabled).toBe(true); + }); +}); + +describe('perPlayerHandler', () => { + test('uno-update just refreshes the hand for the player', () => { + const player = makePlayer('p0', 0, [{ + name: '5', + color: 'red' + }], {turn: true}); + const game = makeGame([player]); + const i = clickInteraction('uno-update'); + perPlayerHandler(i, player, game); + expect(i.update).toHaveBeenCalledWith(expect.objectContaining({content: null})); + }); + + test('off-turn player clicking a card is told it is not their turn', () => { + const player = makePlayer('p0', 0, [{ + name: '5', + color: 'red' + }], {turn: false}); + const game = makeGame([player]); + const i = clickInteraction('uno-card-5-red-0'); + perPlayerHandler(i, player, game); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({ephemeral: true})); + }); + + test('playing an invalid card reports invalid-card and keeps the card', () => { + const player = makePlayer('p0', 0, [{ + name: '9', + color: 'blue' + }, { + name: '3', + color: 'green' + }, { + name: '4', + color: 'yellow' + }], {turn: true}); + const game = makeGame([player], { + name: '5', + color: 'red' + }); // 9/blue matches neither + const i = clickInteraction('uno-card-9-blue-0'); + perPlayerHandler(i, player, game); + expect(i.update).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('uno.invalid-card')})); + expect(player.cards.length).toBe(3); // unchanged + }); + + test('playing the last card wins the game', () => { + const player = makePlayer('p0', 0, [{ + name: '5', + color: 'red' + }], { + turn: true, + uno: true + }); + const player2 = makePlayer('p1', 1, [{ + name: '1', + color: 'red' + }]); + const game = makeGame([player, player2], { + name: '5', + color: 'red' + }); + const i = clickInteraction('uno-card-5-red-0'); + perPlayerHandler(i, player, game); + expect(i.update).toHaveBeenCalledWith(expect.objectContaining({ + content: 'uno.win-you', + components: [] + })); + expect(game.msg.edit).toHaveBeenCalledWith(expect.objectContaining({content: expect.stringContaining('uno.win')})); + }); + + test('forgetting to call uno at 2 cards draws a penalty card and passes the turn', () => { + const player = makePlayer('p0', 0, [{ + name: '5', + color: 'red' + }, { + name: '7', + color: 'red' + }], { + turn: true, + uno: false + }); + const player2 = makePlayer('p1', 1, [{ + name: '1', + color: 'red' + }]); + const game = makeGame([player, player2], { + name: '5', + color: 'red' + }); + const i = clickInteraction('uno-card-5-red-0'); + perPlayerHandler(i, player, game); + expect(i.update).toHaveBeenCalledWith(expect.objectContaining({content: 'uno.missing-uno'})); + expect(player.cards.length).toBe(3); // drew the penalty card instead of playing + expect(player2.turn).toBe(true); + }); + + test('playing a reverse flips the direction', () => { + const player = makePlayer('p0', 0, [{ + name: REVERSE, + color: 'red' + }, { + name: '2', + color: 'red' + }, { + name: '3', + color: 'red' + }], {turn: true}); + const player2 = makePlayer('p1', 1, [{ + name: '1', + color: 'red' + }]); + const game = makeGame([player, player2], { + name: '5', + color: 'red' + }); + const i = clickInteraction(`uno-card-${REVERSE}-red-0`); + perPlayerHandler(i, player, game); + expect(game.reversed).toBe(true); + expect(game.lastCard).toEqual({ + name: REVERSE, + color: 'red' + }); + }); + + test('playing a wild prompts for a colour choice', () => { + const player = makePlayer('p0', 0, [{ + name: WILD, + color: 'red' + }, { + name: '2', + color: 'red' + }, { + name: '3', + color: 'red' + }], {turn: true}); + const game = makeGame([player, makePlayer('p1', 1, [{}])], { + name: '5', + color: 'red' + }); + const i = clickInteraction(`uno-card-${WILD}-red-0`); + perPlayerHandler(i, player, game); + expect(i.update).toHaveBeenCalledWith(expect.objectContaining({content: 'uno.choose-color'})); + }); + + test('choosing a colour sets lastCard colour and advances the turn', () => { + const player = makePlayer('p0', 0, [{ + name: '2', + color: 'red' + }], {turn: true}); + const player2 = makePlayer('p1', 1, [{ + name: '1', + color: 'red' + }]); + const game = makeGame([player, player2], { + name: WILD, + color: 'red' + }); + const i = clickInteraction(`uno-color-blue-${WILD}`); + perPlayerHandler(i, player, game); + expect(game.lastCard).toEqual({ + name: WILD, + color: 'blue' + }); + expect(player2.turn).toBe(true); + }); +}); + +describe('nextPlayer inactivity timers', () => { + test('schedules an inactivity warning that mentions the next player after 60s', () => { + const players = [makePlayer('p0', 0, [{}], {turn: true}), makePlayer('p1', 1, [{}])]; + const game = makeGame(players); + nextPlayer(game, players[0]); + expect(players[1].turn).toBe(true); + jest.advanceTimersByTime(60 * 1000); + expect(game.msg.channel.send).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('uno.inactive-warn') + })); + }); + + test('kicks an inactive player after 2 minutes and ends the game when one remains', () => { + const players = [makePlayer('p0', 0, [{}], {turn: true}), makePlayer('p1', 1, [{}])]; + const game = makeGame(players); + nextPlayer(game, players[0]); // p1 now on turn + jest.advanceTimersByTime(2 * 60 * 1000); + expect(game.msg.edit).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('uno.inactive-win'), + components: [] + })); + }); +}); + +describe('run lobby', () => { + function makeRunInteraction(hostId = 'host') { + const collector = { + handlers: {}, + on(e, fn) { + this.handlers[e] = fn; + return this; + }, + stop: jest.fn() + }; + const msg = { + id: 'm', + channel: {id: 'c'}, + createMessageComponentCollector: jest.fn().mockReturnValue(collector), + edit: jest.fn().mockResolvedValue() + }; + const interaction = { + user: { + id: hostId, + toString: () => `<@${hostId}>` + }, + reply: jest.fn().mockResolvedValue(msg), + editReply: jest.fn().mockResolvedValue(), + followUp: jest.fn().mockResolvedValue({ + createMessageComponentCollector: () => ({ + on: () => { + } + }) + }) + }; + return { + interaction, + collector, + msg + }; + } + + function lobbyClick(customId, userId) { + return { + customId, + user: {id: userId}, + update: jest.fn().mockResolvedValue(), + reply: jest.fn().mockResolvedValue(), + deferUpdate: jest.fn().mockResolvedValue(), + followUp: jest.fn().mockResolvedValue({ + createMessageComponentCollector: () => ({ + on: () => { + } + }) + }) + }; + } + + test('posts a challenge message with join/start buttons', async () => { + const { + interaction, + msg + } = makeRunInteraction(); + await uno.run(interaction); + const payload = interaction.reply.mock.calls[0][0]; + expect(payload.content).toContain('uno.challenge-message'); + expect(payload.components[0].components.map(c => c.customId)).toEqual(['uno-join', 'uno-start']); + expect(msg.createMessageComponentCollector).toHaveBeenCalled(); + }); + + test('a second user can join and the count updates', async () => { + const { + interaction, + collector + } = makeRunInteraction(); + await uno.run(interaction); + const i = lobbyClick('uno-join', 'guest'); + await collector.handlers.collect(i); + expect(i.update).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('uno.challenge-message') + })); + }); + + test('joining twice is rejected', async () => { + const { + interaction, + collector + } = makeRunInteraction('host'); + await uno.run(interaction); + // host is already player[0] + const i = lobbyClick('uno-join', 'host'); + await collector.handlers.collect(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'uno.already-joined'})); + }); + + test('a non-host cannot start the game', async () => { + const { + interaction, + collector + } = makeRunInteraction('host'); + await uno.run(interaction); + const i = lobbyClick('uno-start', 'guest'); + await collector.handlers.collect(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'uno.not-host'})); + }); + + test('host starting with too few players reports not-enough-players', async () => { + const { + interaction, + collector + } = makeRunInteraction('host'); + await uno.run(interaction); + const i = lobbyClick('uno-start', 'host'); + await collector.handlers.collect(i); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: 'uno.not-enough-players', + components: [] + })); + }); + + test('the uno button on a non-participant is rejected', async () => { + const { + interaction, + collector + } = makeRunInteraction('host'); + await uno.run(interaction); + const i = lobbyClick('uno-uno', 'stranger'); + await collector.handlers.collect(i); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'uno.not-in-game'})); + }); +}); \ No newline at end of file diff --git a/tests/welcomer/baseRoles.test.js b/tests/welcomer/baseRoles.test.js new file mode 100644 index 00000000..5643e21a --- /dev/null +++ b/tests/welcomer/baseRoles.test.js @@ -0,0 +1,263 @@ +/* + * Stub localize before requiring baseRoles, since the real module (loaded lazily inside + * runSync) pulls in main.js via its top-level require chain and would crash the test runner. + */ +jest.mock('../../src/functions/localize', () => ({localize: (file, key) => `${file}.${key}`})); + +const { + isInHoldingState, + evaluateMember, + runSync +} = require('../../modules/welcomer/baseRoles'); + +/** + * Builds a GuildMember-shaped stub for testing. + * @param {Object} [opts] + * @returns {Object} + */ +function makeMember(opts = {}) { + return { + id: opts.id || 'u1', + user: {bot: !!opts.bot}, + pending: !!opts.pending, + roles: { + cache: { + has: (id) => (opts.roleIDs || []).includes(id) + } + } + }; +} + +/** + * Builds a Client-shaped stub for testing. + * @param {Object} [opts] + * @returns {Object} + */ +function makeClient(opts = {}) { + return { + configurations: { + welcomer: { + config: { + 'assign-roles-immediately': opts.assignImmediately !== false, + 'give-roles-on-join': opts.joinRoles || ['r1', 'r2'] + } + }, + moderation: { + config: {'quarantine-role-id': opts.quarantineRoleID || 'qrole'}, + joinGate: { + enabled: !!opts.joinGateEnabled, + action: opts.joinGateAction || 'give-role', + roleID: opts.joinGateRoleID || 'jgrole' + }, + antiJoinRaid: { + enabled: !!opts.antiRaidEnabled, + action: opts.antiRaidAction || 'give-role', + roleID: opts.antiRaidRoleID || 'arrole' + } + } + }, + modules: {moderation: {enabled: opts.moderationEnabled !== false}}, + models: { + moderation: { + QuarantineState: { + findByPk: async (id) => { + return (opts.quarantineStateRows || []).includes(id) ? {victimID: id} : null; + } + } + } + } + }; +} + +describe('isInHoldingState', () => { + test('returns true for bots', async () => { + const member = makeMember({bot: true}); + expect(await isInHoldingState(member, makeClient())).toBe(true); + }); + + test('returns true when member holds the quarantine role', async () => { + const member = makeMember({roleIDs: ['qrole']}); + expect(await isInHoldingState(member, makeClient())).toBe(true); + }); + + test('returns true when a QuarantineState row exists', async () => { + const member = makeMember({id: 'u1'}); + const client = makeClient({quarantineStateRows: ['u1']}); + expect(await isInHoldingState(member, client)).toBe(true); + }); + + test('returns true when member holds the JoinGate hold role and JoinGate uses give-role', async () => { + const member = makeMember({roleIDs: ['jgrole']}); + const client = makeClient({joinGateEnabled: true}); + expect(await isInHoldingState(member, client)).toBe(true); + }); + + test('returns false when JoinGate hold role present but JoinGate disabled', async () => { + const member = makeMember({roleIDs: ['jgrole']}); + const client = makeClient({joinGateEnabled: false}); + expect(await isInHoldingState(member, client)).toBe(false); + }); + + test('returns true when member holds the anti-raid hold role and anti-raid uses give-role', async () => { + const member = makeMember({roleIDs: ['arrole']}); + const client = makeClient({antiRaidEnabled: true}); + expect(await isInHoldingState(member, client)).toBe(true); + }); + + test('returns true when member is pending and assign-roles-immediately is false', async () => { + const member = makeMember({pending: true}); + const client = makeClient({assignImmediately: false}); + expect(await isInHoldingState(member, client)).toBe(true); + }); + + test('returns false when member is pending but assign-roles-immediately is true', async () => { + const member = makeMember({pending: true}); + const client = makeClient({assignImmediately: true}); + expect(await isInHoldingState(member, client)).toBe(false); + }); + + test('returns false for a regular non-bot member with no holding markers', async () => { + const member = makeMember(); + expect(await isInHoldingState(member, makeClient())).toBe(false); + }); + + test('returns false when moderation module is disabled (no quarantine/joinGate/raid checks apply)', async () => { + const member = makeMember({roleIDs: ['qrole']}); + const client = makeClient({moderationEnabled: false}); + expect(await isInHoldingState(member, client)).toBe(false); + }); +}); + +describe('evaluateMember', () => { + test('returns skip=true for members in holding state', async () => { + const member = makeMember({bot: true}); + const out = await evaluateMember(member, makeClient()); + expect(out.skip).toBe(true); + expect(out.missingRoleIDs).toEqual([]); + }); + + test('returns the list of missing join roles for a regular member', async () => { + const member = makeMember({roleIDs: ['r1']}); + const out = await evaluateMember(member, makeClient({joinRoles: ['r1', 'r2', 'r3']})); + expect(out.skip).toBe(false); + expect(out.missingRoleIDs).toEqual(['r2', 'r3']); + }); + + test('returns missingRoleIDs=[] when the member already has all join roles', async () => { + const member = makeMember({roleIDs: ['r1', 'r2']}); + const out = await evaluateMember(member, makeClient({joinRoles: ['r1', 'r2']})); + expect(out.skip).toBe(false); + expect(out.missingRoleIDs).toEqual([]); + }); + + test('handles empty give-roles-on-join configuration', async () => { + const member = makeMember(); + const out = await evaluateMember(member, makeClient({joinRoles: []})); + expect(out.skip).toBe(false); + expect(out.missingRoleIDs).toEqual([]); + }); +}); + +describe('runSync', () => { + + /** + * Full Client stub including guild.members.cache and a no-op logger. + * @param {Object} [overrides] + * @returns {Object} + */ + function makeFullClient(overrides = {}) { + const cache = new Map(); + (overrides.members || []).forEach(m => cache.set(m.id, m)); + return { + configurations: { + welcomer: { + config: { + 'treat-welcome-roles-as-base-roles': overrides.enabled !== false, + 'give-roles-on-join': overrides.joinRoles || ['r1', 'r2'], + 'assign-roles-immediately': true + } + }, + moderation: { + config: {'quarantine-role-id': 'qrole'}, + joinGate: {enabled: false, action: 'give-role', roleID: 'jgrole'}, + antiJoinRaid: {enabled: false, action: 'give-role', roleID: 'arrole'} + } + }, + modules: {moderation: {enabled: true}}, + models: {moderation: {QuarantineState: {findByPk: async () => null}}}, + guild: {members: {cache}}, + logger: { + info: () => { + }, error: () => { + }, warn: () => { + } + } + }; + } + + /** + * GuildMember stub with role-add side effects captured into addImpl. + * @param {string} id + * @param {string[]} [roleIDs] + * @param {Object} [opts] + * @returns {Object} + */ + function makeRealMember(id, roleIDs = [], opts = {}) { + return { + id, + user: {bot: !!opts.bot}, + pending: !!opts.pending, + roles: { + cache: {has: (rid) => roleIDs.includes(rid)}, + add: opts.addImpl || (async () => { + }) + } + }; + } + + test('does nothing when the option is disabled', async () => { + const adds = []; + const member = makeRealMember('u1', [], {addImpl: async (r) => adds.push(r)}); + const client = makeFullClient({enabled: false, members: [member]}); + const result = await runSync(client); + expect(result).toBeUndefined(); + expect(adds).toEqual([]); + }); + + test('grants missing join roles to a regular member', async () => { + const adds = []; + const member = makeRealMember('u1', ['r1'], {addImpl: async (r) => adds.push(r)}); + const client = makeFullClient({members: [member]}); + const result = await runSync(client); + expect(result).toEqual({scanned: 1, granted: 1, skipped: 0, failed: 0}); + expect(adds.length).toBe(1); + expect(adds[0]).toEqual(['r2']); + }); + + test('skips members in holding state', async () => { + const adds = []; + const member = makeRealMember('u1', ['qrole'], {addImpl: async (r) => adds.push(r)}); + const client = makeFullClient({members: [member]}); + const result = await runSync(client); + expect(result).toEqual({scanned: 1, granted: 0, skipped: 1, failed: 0}); + expect(adds).toEqual([]); + }); + + test('skips members who already have all join roles', async () => { + const member = makeRealMember('u1', ['r1', 'r2']); + const client = makeFullClient({members: [member]}); + const result = await runSync(client); + expect(result).toEqual({scanned: 1, granted: 0, skipped: 1, failed: 0}); + }); + + test('counts a failure when roles.add rejects', async () => { + const member = makeRealMember('u1', [], { + addImpl: async () => { + throw new Error('Missing Permissions'); + } + }); + const client = makeFullClient({members: [member]}); + const result = await runSync(client); + expect(result).toEqual({scanned: 1, granted: 0, skipped: 0, failed: 1}); + }); +}); \ No newline at end of file diff --git a/tests/welcomer/baseRolesAdvanced.test.js b/tests/welcomer/baseRolesAdvanced.test.js new file mode 100644 index 00000000..d2e504fa --- /dev/null +++ b/tests/welcomer/baseRolesAdvanced.test.js @@ -0,0 +1,218 @@ +/* + * Tests for the welcomer base-role reactive helpers in baseRoles.js that the + * existing baseRoles.test.js does not cover: handleHoldingRelease, checkWatchdog, + * and the guard/debounce wiring of handleRoleRemoval. + * + * - handleHoldingRelease: when a quarantine/JoinGate/anti-raid hold role is removed + * and the member is no longer held, missing join roles are re-granted. + * - checkWatchdog: after a re-add we watch for a quarantine role appearing within + * the window and revert the just-granted roles. + * - handleRoleRemoval: short-circuits when base roles aren't enabled / no removal + * happened, and otherwise schedules a debounced re-add (asserted via the pending + * debounce map + the deferred fetch). + */ +const baseRoles = require('../../modules/welcomer/baseRoles'); +const { + handleHoldingRelease, + checkWatchdog, + handleRoleRemoval, + _state +} = baseRoles; + +beforeEach(() => { + jest.useFakeTimers(); + _state.recentReadds.clear(); + _state.watchdogTimers.clear(); + _state.pendingDebounces.clear(); +}); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +function roleCache(ids) { + return {cache: {has: (id) => ids.includes(id)}}; +} + +function makeMember(id, roleIds, extra = {}) { + return { + id, + roles: { + ...roleCache(roleIds), + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + }, + ...extra + }; +} + +function makeClient({ + joinRoles = ['baseRole'], + baseRolesEnabled = true, + moderation = null + } = {}) { + const client = { + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + }, + configurations: { + welcomer: { + config: { + 'treat-welcome-roles-as-base-roles': baseRolesEnabled, + 'give-roles-on-join': joinRoles, + 'assign-roles-immediately': true + } + } + } + }; + if (moderation) { + client.modules = {moderation: {enabled: true}}; + client.configurations.moderation = moderation; + } + return client; +} + +describe('handleHoldingRelease', () => { + test('grants missing join roles when a quarantine hold is released', async () => { + const client = makeClient({ + moderation: {config: {'quarantine-role-id': 'qRole'}} + }); + const oldMember = makeMember('m1', ['qRole']); // was quarantined + const newMember = makeMember('m1', []); // quarantine removed, missing baseRole + await handleHoldingRelease(client, oldMember, newMember); + expect(newMember.roles.add).toHaveBeenCalledWith(['baseRole'], expect.any(String)); + expect(client.logger.info).toHaveBeenCalled(); + }); + + test('does nothing when base-role treatment is disabled', async () => { + const client = makeClient({ + baseRolesEnabled: false, + moderation: {config: {'quarantine-role-id': 'qRole'}} + }); + const oldMember = makeMember('m1', ['qRole']); + const newMember = makeMember('m1', []); + await handleHoldingRelease(client, oldMember, newMember); + expect(newMember.roles.add).not.toHaveBeenCalled(); + }); + + test('does nothing when no hold role was actually released', async () => { + const client = makeClient({moderation: {config: {'quarantine-role-id': 'qRole'}}}); + const oldMember = makeMember('m1', []); // never held + const newMember = makeMember('m1', []); + await handleHoldingRelease(client, oldMember, newMember); + expect(newMember.roles.add).not.toHaveBeenCalled(); + }); + + test('does not grant when the member already has every join role', async () => { + const client = makeClient({moderation: {config: {'quarantine-role-id': 'qRole'}}}); + const oldMember = makeMember('m1', ['qRole']); + const newMember = makeMember('m1', ['baseRole']); // already has it + await handleHoldingRelease(client, oldMember, newMember); + expect(newMember.roles.add).not.toHaveBeenCalled(); + }); + + test('does not grant while the member is still in another holding state', async () => { + // quarantine released but the member now holds the JoinGate hold role + const client = makeClient({ + moderation: { + config: {'quarantine-role-id': 'qRole'}, + joinGate: { + enabled: true, + action: 'give-role', + roleID: 'gateRole' + } + } + }); + const oldMember = makeMember('m1', ['qRole', 'gateRole']); + const newMember = makeMember('m1', ['gateRole']); // still gated + await handleHoldingRelease(client, oldMember, newMember); + expect(newMember.roles.add).not.toHaveBeenCalled(); + }); +}); + +describe('checkWatchdog', () => { + test('reverts the granted roles when a quarantine role appears within the window', async () => { + const client = makeClient({moderation: {config: {'quarantine-role-id': 'qRole'}}}); + // Seed an active watchdog directly. + _state.watchdogTimers.set('m1', { + timer: setTimeout(() => { + }, 5000), + quarantineRoleID: 'qRole', + grantedRoleIDs: ['baseRole'], + deadline: Date.now() + 5000 + }); + const oldMember = makeMember('m1', []); // no quarantine before + const newMember = makeMember('m1', ['qRole']); // quarantine appeared + await checkWatchdog(client, oldMember, newMember); + expect(newMember.roles.remove).toHaveBeenCalledWith(['baseRole'], expect.any(String)); + expect(_state.watchdogTimers.has('m1')).toBe(false); + }); + + test('does nothing when no watchdog is active for the member', async () => { + const client = makeClient(); + const newMember = makeMember('m1', ['qRole']); + await checkWatchdog(client, makeMember('m1', []), newMember); + expect(newMember.roles.remove).not.toHaveBeenCalled(); + }); + + test('clears an expired watchdog without reverting', async () => { + const client = makeClient(); + _state.watchdogTimers.set('m1', { + timer: setTimeout(() => { + }, 5000), + quarantineRoleID: 'qRole', + grantedRoleIDs: ['baseRole'], + deadline: Date.now() - 1 // already expired + }); + const newMember = makeMember('m1', ['qRole']); + await checkWatchdog(client, makeMember('m1', []), newMember); + expect(newMember.roles.remove).not.toHaveBeenCalled(); + expect(_state.watchdogTimers.has('m1')).toBe(false); + }); +}); + +describe('handleRoleRemoval guards + debounce', () => { + test('short-circuits when base-role treatment is disabled', async () => { + const client = makeClient({baseRolesEnabled: false}); + const oldMember = makeMember('m1', ['baseRole']); + const newMember = makeMember('m1', []); + await handleRoleRemoval(client, oldMember, newMember); + expect(_state.pendingDebounces.has('m1')).toBe(false); + }); + + test('does nothing when no join role was removed', async () => { + const client = makeClient(); + const oldMember = makeMember('m1', ['baseRole']); + const newMember = makeMember('m1', ['baseRole']); // unchanged + await handleRoleRemoval(client, oldMember, newMember); + expect(_state.pendingDebounces.has('m1')).toBe(false); + }); + + test('schedules a debounced re-add when a join role is removed', async () => { + const client = makeClient(); + const oldMember = makeMember('m1', ['baseRole']); + const newMember = makeMember('m1', []); + // members.fetch returns a member that already has the role -> re-add no-ops, but the + // important assertion is that a debounce timer was registered. + newMember.guild = {members: {fetch: jest.fn().mockResolvedValue(makeMember('m1', ['baseRole']))}}; + await handleRoleRemoval(client, oldMember, newMember); + expect(_state.pendingDebounces.has('m1')).toBe(true); + // drive the debounce; the fetch should fire and the pending entry cleared + await jest.advanceTimersByTimeAsync(1500); + expect(newMember.guild.members.fetch).toHaveBeenCalled(); + expect(_state.pendingDebounces.has('m1')).toBe(false); + }); + + test('ignores a second removal while a debounce is already pending', async () => { + const client = makeClient(); + const oldMember = makeMember('m1', ['baseRole']); + const newMember = makeMember('m1', []); + newMember.guild = {members: {fetch: jest.fn().mockResolvedValue(makeMember('m1', ['baseRole']))}}; + await handleRoleRemoval(client, oldMember, newMember); + const firstTimer = _state.pendingDebounces.get('m1'); + await handleRoleRemoval(client, oldMember, newMember); // second call, still pending + expect(_state.pendingDebounces.get('m1')).toBe(firstTimer); + }); +}); \ No newline at end of file diff --git a/tests/welcomer/events.test.js b/tests/welcomer/events.test.js new file mode 100644 index 00000000..e8b9cc3f --- /dev/null +++ b/tests/welcomer/events.test.js @@ -0,0 +1,477 @@ +/* + * Behavior tests for the welcomer event handlers. + * + * - guildMemberAdd: guards (not ready / wrong guild / bot+suppress), DM on join, + * immediate vs deferred role assignment, sending join messages, and persisting + * the sent message (create new vs update existing welcomer User row). + * - guildMemberRemove: sends leave messages and (when enabled) deletes the stored + * welcome message within the 7-day window. + * - guildMemberUpdate: posts boost / unboost messages on premium transitions and + * grants/removes boost roles. + * - interactionCreate: the welcome-button flow — self-press guard, missing-channel + * guards, removing the clicked button, and posting the welcome-button message. + * + * baseRoles side-channels (handleRoleRemoval etc.) are mocked out for the update + * handler so we isolate the boost behaviour. localize/main are jest-mapped stubs; + * embedType/embedTypeV2 run for real. + */ + +beforeEach(() => jest.useFakeTimers()); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +function makeUser(id = 'u1') { + return { + id, + bot: false, + username: 'User', + discriminator: '0', + bot_: false, + toString: () => `<@${id}>`, + fetch: jest.fn().mockResolvedValue(), + send: jest.fn().mockResolvedValue(), + avatarURL: () => 'http://a/u.png', + defaultAvatarURL: 'http://a/def.png', + bannerURL: () => 'http://a/banner.png', + createdAt: new Date('2020-01-01') + }; +} + +function membersCache(size = 5) { + return { + size, + filter: () => ({size: size - 1}) + }; +} + +function makeClient(overrides = {}) { + return { + botReadyAt: Date.now(), + guild: { + id: 'g1', + name: 'Guild', + premiumTier: 2, + premiumSubscriptionCount: 3, + members: {cache: membersCache()} + }, + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn() + }, + users: {fetch: jest.fn()}, + configurations: { + 'welcomer': { + config: {}, + channels: [], + 'random-messages': [] + } + }, + models: { + 'welcomer': { + User: { + findOne: jest.fn().mockResolvedValue(null), + findAll: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}) + } + } + }, + ...overrides + }; +} + +function makeMember({ + id = 'u1', + bot = false, + pending = false, + guildId = 'g1', + premiumSince = null + } = {}) { + const user = makeUser(id); + user.bot = bot; + return { + id, + user, + pending, + premiumSince, + joinedAt: new Date('2024-01-01'), + guild: { + id: guildId, + name: 'Guild', + channels: {fetch: jest.fn()} + }, + roles: { + add: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue(), + cache: {has: () => false} + }, + toString: () => `<@${id}>`, + fetch: jest.fn().mockResolvedValue() + }; +} + +describe('guildMemberAdd', () => { + const handler = require('../../modules/welcomer/events/guildMemberAdd'); + + function configure(client, { + channels = [], + config = {} + } = {}) { + client.configurations.welcomer.channels = channels; + client.configurations.welcomer.config = {'give-roles-on-join': [], ...config}; + } + + test('ignores joins before the bot is ready', async () => { + const client = makeClient(); + client.botReadyAt = null; + configure(client); + const member = makeMember(); + await handler.run(client, member); + expect(member.user.fetch).not.toHaveBeenCalled(); + }); + + test('ignores joins from another guild', async () => { + const client = makeClient(); + configure(client); + await handler.run(client, makeMember({guildId: 'other'})); + expect(client.models.welcomer.User.create).not.toHaveBeenCalled(); + }); + + test('skips bots when not-send-messages-if-member-is-bot is set', async () => { + const client = makeClient(); + configure(client, { + config: { + 'not-send-messages-if-member-is-bot': true, + 'give-roles-on-join': [] + } + }); + const member = makeMember({bot: true}); + await handler.run(client, member); + expect(member.user.fetch).not.toHaveBeenCalled(); + }); + + test('sends a join DM when sendDirectMessageOnJoin is enabled', async () => { + const client = makeClient(); + configure(client, { + config: { + sendDirectMessageOnJoin: true, + joinDM: 'hi %mention%', + 'give-roles-on-join': [] + } + }); + const member = makeMember(); + await handler.run(client, member); + expect(member.user.send).toHaveBeenCalled(); + }); + + test('sends the join message and creates a welcomer User row', async () => { + const client = makeClient(); + const channel = { + send: jest.fn().mockResolvedValue({ + id: 'sent1', + channelId: 'wc' + }) + }; + const member = makeMember(); + member.guild.channels.fetch = jest.fn().mockResolvedValue(channel); + configure(client, { + channels: [{ + type: 'join', + channelID: 'wc', + message: 'welcome %mention%' + }], + config: {'give-roles-on-join': []} + }); + await handler.run(client, member); + expect(channel.send).toHaveBeenCalled(); + expect(client.models.welcomer.User.create).toHaveBeenCalledWith(expect.objectContaining({ + userID: 'u1', + channelID: 'wc', + messageID: 'sent1' + })); + }); + + test('updates an existing welcomer User row instead of creating a new one', async () => { + const client = makeClient(); + const existing = {update: jest.fn().mockResolvedValue()}; + client.models.welcomer.User.findOne.mockResolvedValue(existing); + const channel = { + send: jest.fn().mockResolvedValue({ + id: 'sent2', + channelId: 'wc' + }) + }; + const member = makeMember(); + member.guild.channels.fetch = jest.fn().mockResolvedValue(channel); + configure(client, { + channels: [{ + type: 'join', + channelID: 'wc', + message: 'welcome' + }], + config: {'give-roles-on-join': []} + }); + await handler.run(client, member); + expect(existing.update).toHaveBeenCalledWith(expect.objectContaining({messageID: 'sent2'})); + expect(client.models.welcomer.User.create).not.toHaveBeenCalled(); + }); + + test('logs an error and skips a channel that cannot be fetched', async () => { + const client = makeClient(); + const member = makeMember(); + member.guild.channels.fetch = jest.fn().mockResolvedValue(null); + configure(client, { + channels: [{ + type: 'join', + channelID: 'missing', + message: 'x' + }], + config: {'give-roles-on-join': []} + }); + await handler.run(client, member); + expect(client.logger.error).toHaveBeenCalled(); + expect(client.models.welcomer.User.create).not.toHaveBeenCalled(); + }); +}); + +describe('assignJoinRoles', () => { + const {assignJoinRoles} = require('../../modules/welcomer/events/guildMemberAdd'); + + test('adds the configured join roles after the 500ms delay', async () => { + const member = makeMember(); + member.client = {logger: {error: jest.fn()}}; + const fresh = {roles: {add: jest.fn().mockResolvedValue()}}; + member.fetch = jest.fn().mockResolvedValue(fresh); + assignJoinRoles(member, {'give-roles-on-join': ['r1', 'r2']}); + await jest.advanceTimersByTimeAsync(500); + expect(fresh.roles.add).toHaveBeenCalledWith(['r1', 'r2'], expect.any(String)); + }); + + test('does nothing when there are no join roles', () => { + const member = makeMember(); + const spy = jest.spyOn(global, 'setTimeout'); + assignJoinRoles(member, {'give-roles-on-join': []}); + expect(spy).not.toHaveBeenCalled(); + }); + + test('respects the doNotGiveWelcomeRole flag set during the delay', async () => { + const member = makeMember(); + member.client = {logger: {error: jest.fn()}}; + member.fetch = jest.fn(); + assignJoinRoles(member, {'give-roles-on-join': ['r1']}); + member.doNotGiveWelcomeRole = true; + await jest.advanceTimersByTimeAsync(500); + expect(member.fetch).not.toHaveBeenCalled(); + }); +}); + +describe('guildMemberRemove', () => { + const handler = require('../../modules/welcomer/events/guildMemberRemove'); + + test('sends a leave message in each leave channel', async () => { + const client = makeClient(); + const channel = {send: jest.fn().mockResolvedValue()}; + const member = makeMember(); + member.guild.channels.fetch = jest.fn().mockResolvedValue(channel); + client.configurations.welcomer.channels = [{ + type: 'leave', + channelID: 'lc', + message: 'bye %mention%' + }]; + client.configurations.welcomer.config = {'delete-welcome-message': false}; + await handler.run(client, member); + expect(channel.send).toHaveBeenCalled(); + }); + + test('deletes the stored welcome message within the 7-day window when enabled', async () => { + const client = makeClient(); + const fetchedMessage = {delete: jest.fn().mockResolvedValue()}; + const channel = { + send: jest.fn().mockResolvedValue(), + messages: {fetch: jest.fn().mockResolvedValue(fetchedMessage)} + }; + const member = makeMember(); + member.guild.channels.fetch = jest.fn().mockResolvedValue(channel); + const row = { + channelID: 'wc', + messageID: 'm9', + timestamp: new Date(), + destroy: jest.fn().mockResolvedValue() + }; + client.models.welcomer.User.findAll.mockResolvedValue([row]); + client.models.welcomer.User.findOne.mockResolvedValue(row); + client.configurations.welcomer.channels = []; + client.configurations.welcomer.config = {'delete-welcome-message': true}; + await handler.run(client, member); + expect(fetchedMessage.delete).toHaveBeenCalled(); + expect(row.destroy).toHaveBeenCalled(); + }); +}); + +describe('guildMemberUpdate boost messages', () => { + // Stub the base-role helpers so we test only the boost path. + jest.mock('../../modules/welcomer/baseRoles', () => ({ + handleRoleRemoval: jest.fn(), + handleHoldingRelease: jest.fn(), + checkWatchdog: jest.fn() + })); + const handler = require('../../modules/welcomer/events/guildMemberUpdate'); + + function boostSetup(type) { + const client = makeClient(); + const channel = {send: jest.fn().mockResolvedValue()}; + client.configurations.welcomer.channels = [{ + type, + channelID: 'bc', + message: 'boost %mention%' + }]; + client.configurations.welcomer.config = {'give-roles-on-boost': ['boostRole']}; + const newMember = makeMember({premiumSince: type === 'boost' ? new Date() : null}); + newMember.guild.channels.fetch = jest.fn().mockResolvedValue(channel); + return { + client, + channel, + newMember + }; + } + + test('sends a boost message and adds the boost role on a new boost', async () => { + const { + client, + channel, + newMember + } = boostSetup('boost'); + const oldMember = makeMember({premiumSince: null}); + await handler.run(client, oldMember, newMember); + expect(channel.send).toHaveBeenCalled(); + expect(newMember.roles.add).toHaveBeenCalledWith(['boostRole']); + }); + + test('sends an unboost message and removes the boost role when boosting stops', async () => { + const { + client, + channel, + newMember + } = boostSetup('unboost'); + const oldMember = makeMember({premiumSince: new Date()}); + await handler.run(client, oldMember, newMember); + expect(channel.send).toHaveBeenCalled(); + expect(newMember.roles.remove).toHaveBeenCalledWith(['boostRole']); + }); + + test('does nothing on an update with no premium transition', async () => { + const { + client, + channel + } = boostSetup('boost'); + const oldMember = makeMember({premiumSince: null}); + const newMember = makeMember({premiumSince: null}); + newMember.guild.channels.fetch = jest.fn().mockResolvedValue(channel); + await handler.run(client, oldMember, newMember); + expect(channel.send).not.toHaveBeenCalled(); + }); +}); + +describe('welcomer interactionCreate (welcome button)', () => { + const handler = require('../../modules/welcomer/events/interactionCreate'); + + function makeInteraction({ + customId = 'welcome-target', + userId = 'clicker', + channels = [], + sendChannel + } = {}) { + return { + isButton: () => true, + customId, + user: { + id: userId, + toString: () => `<@${userId}>`, + avatarURL: () => 'http://a/c.png', + username: 'C', + discriminator: '0' + }, + channel: {id: 'jc'}, + message: {components: []}, + guild: {channels: {cache: {get: () => sendChannel}}}, + reply: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue(), + client: { + users: { + fetch: jest.fn().mockResolvedValue({ + id: 'target', + toString: () => '<@target>', + avatarURL: () => 'http://a/t.png', + username: 'T', + discriminator: '0' + }) + }, + configurations: {welcomer: {channels}} + } + }; + } + + test('ignores non-button interactions', async () => { + const interaction = makeInteraction(); + interaction.isButton = () => false; + await handler.run({}, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + }); + + test('ignores buttons that are not welcome buttons', async () => { + const interaction = makeInteraction({customId: 'something-else'}); + await handler.run({configurations: {welcomer: {channels: []}}}, interaction); + expect(interaction.reply).not.toHaveBeenCalled(); + }); + + test('refuses to welcome yourself', async () => { + const interaction = makeInteraction({ + customId: 'welcome-clicker', + userId: 'clicker' + }); + await handler.run(interaction.client, interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('welcome-yourself-error') + })); + }); + + test('removes the clicked button and posts the welcome-button message', async () => { + const sendChannel = {send: jest.fn().mockResolvedValue()}; + const channels = [{ + channelID: 'jc', + type: 'join', + 'welcome-button-channel': 'send-ch', + 'welcome-button-message': 'welcomed by %clickUserMention%' + }]; + const interaction = makeInteraction({ + channels, + sendChannel + }); + await handler.run(interaction.client, interaction); + expect(interaction.update).toHaveBeenCalled(); + expect(sendChannel.send).toHaveBeenCalled(); + const payload = JSON.stringify(sendChannel.send.mock.calls[0][0]); + expect(payload).toContain('clicker'); + }); + + test('warns when the configured welcome-button target channel is missing', async () => { + const channels = [{ + channelID: 'jc', + type: 'join', + 'welcome-button-channel': 'gone' + }]; + const interaction = makeInteraction({ + channels, + sendChannel: undefined + }); + await handler.run(interaction.client, interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('channel-not-found') + })); + expect(interaction.update).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file