diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..6d53d5370
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,55 @@
+# CodeRabbit configuration — Lucky
+# Docs: https://docs.coderabbit.ai/getting-started/configure-coderabbit
+#
+# Goal: stop nit-spam from blocking the merge gate. Keep substantive
+# bug-finding active. Per workflow.md merge rule: tools should approve
+# when there's no real concern, not when there's no comment at all.
+language: en-US
+
+reviews:
+ # 'chill' = minor/nit comments are summarized rather than posted as
+ # actionable threads that flip the PR to CHANGES_REQUESTED.
+ profile: chill
+ # Never auto-block merges on style / opinion. The merge rule already
+ # gates on substantive concerns, not bot mood.
+ request_changes_workflow: false
+ # Keep the high-level summary so reviewers see a TL;DR.
+ high_level_summary: true
+ # No poems — they're noise in the comment timeline.
+ poem: false
+ # Don't post the per-file walkthrough as a separate review status.
+ review_status: false
+ # Collapse the walkthrough so it's clickable but not screen-eating.
+ collapse_walkthrough: true
+ # Skip auto-review on these paths — they're either generated or
+ # policy artifacts where bot opinions don't add value.
+ path_filters:
+ - '!**/*.lock'
+ - '!**/package-lock.json'
+ - '!**/dist/**'
+ - '!**/build/**'
+ - '!**/.next/**'
+ - '!**/generated/**'
+ - '!**/__generated__/**'
+ - '!**/CHANGELOG.md'
+ - '!**/*.snap'
+
+ auto_review:
+ enabled: true
+ # Don't review on draft PRs — saves rate-limit budget.
+ drafts: false
+ # Run on these base branches; release/** and main only.
+ base_branches:
+ - main
+ - 'release/.*'
+
+chat:
+ # Don't auto-reply to comments — keeps the thread quiet.
+ auto_reply: false
+
+# Knowledge-base sources CodeRabbit reads for context.
+knowledge_base:
+ learnings:
+ scope: auto
+ issues:
+ scope: auto
diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml
index 95c621a7a..c6198d11c 100644
--- a/.github/workflows/bundle-size.yml
+++ b/.github/workflows/bundle-size.yml
@@ -2,7 +2,7 @@ name: Bundle Size
on:
pull_request:
- branches: [main]
+ branches: [main, 'release/**']
paths:
- 'packages/frontend/**'
- 'package.json'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d84a3f50e..88c844b23 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI/CD Pipeline
on:
push:
- branches: [main]
+ branches: [main, 'release/**']
pull_request:
- branches: [main]
+ branches: [main, 'release/**']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -89,7 +89,11 @@ jobs:
run: npx secretlint "**/*"
- name: TruffleHog secret scan
- uses: trufflesecurity/trufflehog@5f47aad1c2df34f7c6230784ce9a5a659922f479 # v3.94.3
+ uses: trufflesecurity/trufflehog@ba0a524d6e51744d9d4e306bc57ac5d6ca5173ea # post-v3.95.2 (2026-05-07)
with:
extra_args: --only-verified
continue-on-error: true
+
+ - name: Socket.dev supply chain scan
+ run: echo "Socket.dev scan -- GitHub App (apps/socket-security) covers PR-level blocking"
+ # SocketDev/socket-security-action@v1 is unavailable; removed to unblock CI
diff --git a/.github/workflows/path-portability.yml b/.github/workflows/path-portability.yml
index 35bdc04f1..849e63b5c 100644
--- a/.github/workflows/path-portability.yml
+++ b/.github/workflows/path-portability.yml
@@ -3,7 +3,7 @@ name: Path Portability
on:
pull_request:
push:
- branches: [main]
+ branches: [main, 'release/**']
jobs:
portability:
diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml
new file mode 100644
index 000000000..c167efbc9
--- /dev/null
+++ b/.github/workflows/pr-agent.yml
@@ -0,0 +1,37 @@
+name: PR Agent
+
+on:
+ pull_request:
+ types: [opened, reopened, ready_for_review]
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ pull_request_review:
+ types: [submitted]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ pr-agent:
+ name: AI Code Review
+ runs-on: ubuntu-latest
+ if: ${{ github.event.sender.type != 'Bot' && (github.event_name != 'issue_comment' || github.event.issue.pull_request != null) }}
+ permissions:
+ issues: write
+ pull-requests: write
+ contents: read
+ steps:
+ - name: PR Agent action
+ uses: Codium-ai/pr-agent@009ba5a116c4d3273368a6dc53a4efdb7904d519 # main
+ env:
+ OPENAI.KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ OPENAI.API_TYPE: anthropic
+ OPENAI.MODEL: claude-sonnet-4-6
+ CONFIG.AI_PROVIDER: anthropic
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ github_action_config.auto_review: 'true'
+ github_action_config.auto_describe: 'true'
+ github_action_config.auto_improve: 'true'
diff --git a/.github/workflows/review-tools.yml b/.github/workflows/review-tools.yml
new file mode 100644
index 000000000..210405248
--- /dev/null
+++ b/.github/workflows/review-tools.yml
@@ -0,0 +1,42 @@
+name: Review Tools
+
+# Calls the org-level reusable workflows in LucasSantana-Dev/.github
+# (single source of truth for the Claude review prompt + Danger runtime).
+#
+# Tag policy: pinned to the mutable `@v1` tag intentionally. We accept the
+# trade-off — automatic adoption of non-breaking central updates is the
+# reason these workflows are centralized at all. Breaking changes ship under
+# a new major (`@v2`); consumers bump explicitly.
+# If a downstream repo needs strict immutability, pin to a commit SHA
+# instead of `@v1`.
+#
+# Repo-specific behavior lives in:
+# - dangerfile.ts (rules)
+# - .coderabbit.yaml (path filters, base branches)
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+ branches:
+ - main
+ - 'release/**'
+ paths-ignore:
+ - '**.md'
+ - 'docs/**'
+ - 'CHANGELOG.md'
+ - 'package-lock.json'
+
+# Cancel superseded runs when a newer commit is pushed to the same PR.
+concurrency:
+ group: review-tools-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ claude-review:
+ uses: LucasSantana-Dev/.github/.github/workflows/claude-review.yml@v1
+ secrets:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+
+ danger:
+ uses: LucasSantana-Dev/.github/.github/workflows/danger.yml@v1
+ secrets: inherit
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index 2a804ab2d..f4c8adb5f 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -2,9 +2,9 @@ name: SonarCloud Analysis
on:
push:
- branches: [main]
+ branches: [main, 'release/**']
pull_request:
- branches: [main]
+ branches: [main, 'release/**']
jobs:
sonarcloud:
diff --git a/.review-tools-config.json b/.review-tools-config.json
new file mode 100644
index 000000000..6c02e769a
--- /dev/null
+++ b/.review-tools-config.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://raw.githubusercontent.com/LucasSantana-Dev/.github/main/schemas/review-tools-config.schema.json",
+ "version": 1,
+ "central_workflow_tag": "v1",
+ "components": {
+ "claude_review": {
+ "action": "anthropics/claude-code-action",
+ "version": "v1",
+ "model": "claude-sonnet-4-6"
+ },
+ "danger": {
+ "package": "danger",
+ "version": "^12",
+ "node": "22"
+ },
+ "coderabbit": {
+ "profile": "chill",
+ "config_path": ".coderabbit.yaml"
+ }
+ },
+ "installed_at": "2026-05-10",
+ "installed_by": "manual (pilot consumer for ADR 2026-05-10-multi-repo-review-tools-rollout)"
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d05ff3399..f094ed611 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.10.0] - 2026-05-13
+
+### Added
+- feat(bot): Spotify API 429 retry with `Retry-After` header parsing — covers both delta-seconds and HTTP-date formats, hardened against unparseable values (#808)
+- feat(autoplay): wire `recentSkipCount` into mood detection so the recommender adapts faster to user skips (#829)
+
+### Changed
+- refactor(autoplay): replace `reason` string with structured `RecommendationBasis { source, signals[] }`; serialization boundary via `serializeBasis()` (#830)
+
+### Fixed
+- fix(autoplay): block Spanish-language gospel tracks from autoplay when Last.fm is not linked, using Spotify-genre fallback and detector hardening (#818, #819, #820, #827)
+- fix(autoplay): prioritize user tracks + Spotify-liked seeds, add sertanejo genre filter, expand Last.fm limits (#817)
+- fix(lastfm): resolve canonical metadata for album art and multi-artist scrobbles (#821)
+- fix(player): harden stream bridge error handling and add 57 missing tests covering reconnect + format negotiation
+- fix(autoplay): remove YouTube fallback from seed search to prevent cross-language drift (#827)
+
+### Internal
+- chore(ci): revamp PR review tooling — Claude review action + Danger rules + chilled CodeRabbit profile, delegated to org-level reusable workflows (#838)
+- ci(bot): pin coverage threshold floor before phase-2 test cleanup (#835)
+- ci: extend workflow triggers to `release/**` branches for trunk-based-with-release-branches flow (#816)
+- test(player): add `streamBridge` and `soundcloudMatcher` test suites
+- chore(deps-dev): bump dev-dependencies group with 14 updates (#833)
+- chore(deps): bump trufflesecurity/trufflehog action SHA (#832)
+- chore(deps): bump base image from `node:22-alpine` to `node:26-alpine` (#831)
+
## [2.9.0] - 2026-05-05
### Added
diff --git a/Dockerfile.frontend b/Dockerfile.frontend
index daf57f592..68ae16cd5 100644
--- a/Dockerfile.frontend
+++ b/Dockerfile.frontend
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
-FROM node:22-alpine AS builder
+FROM node:26-alpine AS builder
WORKDIR /app
diff --git a/README.md b/README.md
index 66625d0c1..7e3a1b81a 100644
--- a/README.md
+++ b/README.md
@@ -6,12 +6,12 @@
- Self-hosted Discord music bot + React dashboard.
- TypeScript monorepo · Discord.js 14 · Prisma 7 · ~2500 tests · Zero prod incidents.
+ The Discord music bot that can't be shut down — because you host it.
+ Self-hosted · Open-source · TypeScript monorepo · ~2500 tests · Zero prod incidents.
- → Invite Lucky ·
+ → Invite Lucky ·
Dashboard ·
Architecture ·
Changelog ·
@@ -20,7 +20,7 @@
-
+
@@ -33,7 +33,7 @@
Lucky is a production-grade Discord bot built as a TypeScript monorepo. Music player with autoplay + recommendations, full moderation suite, auto-mod presets, and a React 19 dashboard — all self-hostable via Docker. Ships with /artist and /album commands for Spotify listening stats, session save/restore, a leveling system with XP and role rewards, and Twitch stream notifications.
-**Live at** [lucky.lucassantana.tech](https://lucky.lucassantana.tech) · [Invite to your server](https://discord.com/oauth2/authorize?client_id=962198089161134131&scope=bot%20applications.commands&permissions=36970496)
+**Live at** [lucky.lucassantana.tech](https://lucky.lucassantana.tech) · [Invite to your server](https://lucky.lucassantana.tech/invite?utm_source=github&utm_medium=readme&utm_campaign=readme-badge)
---
@@ -51,13 +51,14 @@ Lucky is a production-grade Discord bot built as a TypeScript monorepo. Music pl
## Why Lucky?
-Most Discord music bots are cloud-only black boxes. Lucky is different:
+Groovy, Rythm, Hydra — the biggest Discord music bots all died. YouTube API enforcement can kill any cloud-only bot overnight. Lucky can't be shut down because you host it.
-- **Self-hostable** — your data, your server, your rules. No dependency on a third-party service staying alive.
+- **Shutdown-proof** — self-hosted means no third-party service can take your bot away. Your server, your uptime.
- **Full-stack** — bot + React 19 dashboard in one monorepo. Control music, moderation, and settings from a web interface.
- **Production-grade** — ~2500 tests, zero production incidents, Sentry monitoring, and SonarCloud quality gates.
- **Multi-source** — YouTube + Spotify + SoundCloud, not locked to one provider.
- **Smart autoplay** — personalized recommendations from listening history, not just random tracks.
+- **No paywall** — every feature included, free forever. No premium tier, no upsells.
---
diff --git a/dangerfile.ts b/dangerfile.ts
new file mode 100644
index 000000000..51528f0da
--- /dev/null
+++ b/dangerfile.ts
@@ -0,0 +1,174 @@
+// Danger rules — deterministic PR checks that don't depend on AI bots.
+// Runs in CI on every PR. Free, open source, no rate limits.
+//
+// Goal: enforce conventions that humans forget (and AI reviewers
+// rate-limit out of) so the merge rule has more to lean on.
+
+import { danger, fail, message, schedule, warn } from 'danger'
+
+const pr = danger.github.pr
+const modified = danger.git.modified_files
+const created = danger.git.created_files
+const all = [...modified, ...created]
+
+// --- 1. PR size warning -----------------------------------------------------
+// Big PRs are merge-risk — flag (don't fail) so reviewers slow down.
+const bigPrLines = pr.additions + pr.deletions
+if (bigPrLines > 800) {
+ warn(
+ `Big PR — **${bigPrLines}** lines changed across **${all.length}** files. ` +
+ `Consider splitting into smaller, reviewable chunks.`,
+ )
+}
+
+// --- 2. Source change without test change -----------------------------------
+// Only enforce inside packages/bot, packages/backend, packages/frontend.
+// Excludes pure type-only / config-only changes.
+const sourceLikelyNeedingTest = (path: string): boolean =>
+ /^packages\/(bot|backend|frontend)\/src\//.test(path) &&
+ !path.endsWith('.d.ts') &&
+ !/\.(spec|test)\.(ts|tsx|js)$/.test(path) &&
+ !path.endsWith('index.ts')
+
+const sourceChanges = all.filter(sourceLikelyNeedingTest)
+const testChanges = all.filter((p) => /\.(spec|test)\.(ts|tsx)$/.test(p))
+
+// Sum additions across only the source files (not the whole PR), so a big
+// CHANGELOG / lockfile / docs change next to a one-line source tweak doesn't
+// trigger the no-tests warning.
+async function countSourceAdditions(): Promise {
+ if (sourceChanges.length === 0) return 0
+ const diffs = await Promise.all(
+ sourceChanges.map((f) => danger.git.diffForFile(f)),
+ )
+ return diffs.reduce((sum, d) => {
+ if (!d?.added) return sum
+ // Danger's TextDiff.added is the raw added content (no `+` prefix).
+ // Count non-empty lines instead of filtering by leading `+`.
+ return sum + d.added.split('\n').filter((l) => l.trim().length > 0).length
+ }, 0)
+}
+
+// --- 3. CHANGELOG reminder for user-facing changes --------------------------
+const changelogTouched = modified.includes('CHANGELOG.md')
+const userFacingPatterns = [
+ /^packages\/bot\/src\/functions\//,
+ /^packages\/backend\/src\/routes\//,
+ /^packages\/frontend\/src\//,
+]
+const userFacingChange = all.some((p) =>
+ userFacingPatterns.some((rx) => rx.test(p)),
+)
+// Match conventional-commit prefixes with separator (colon, slash, parens)
+// so titles like "refactoring auth" don't accidentally count as `refactor`.
+const TITLE_PREFIX_SKIP =
+ /^(chore|test|docs|refactor|ci|build|style|perf)(\([^)]*\))?:\s/
+if (userFacingChange && !changelogTouched && !TITLE_PREFIX_SKIP.test(pr.title)) {
+ message(
+ `User-facing change without a CHANGELOG.md update. ` +
+ `Add a line under \`## [Unreleased]\` if this should appear in release notes.`,
+ )
+}
+
+// --- 4. Lockfile guard ------------------------------------------------------
+// package.json without package-lock.json (or vice versa) usually means
+// someone forgot to commit one half of a dependency change.
+const packageJsonChanged = modified.some((p) => /(^|\/)package\.json$/.test(p))
+// Cover both modified and newly-created lockfiles (first-time npm install case).
+const lockChanged = all.some((p) => p === 'package-lock.json')
+if (packageJsonChanged && !lockChanged) {
+ fail(
+ `**\`package.json\` changed but \`package-lock.json\` did not.** ` +
+ `Run \`npm install\` and commit the lockfile.`,
+ )
+}
+
+// --- 5. .env protection -----------------------------------------------------
+// CLAUDE.md notes .env* is hooked-protected, but PRs can still slip
+// through if the hook was bypassed locally. Belt and suspenders.
+const envFiles = all.filter((p) => /(^|\/)\.env(\.|$)/.test(p))
+if (envFiles.length > 0) {
+ fail(
+ `**.env* files in PR:** ${envFiles.join(', ')}. ` +
+ `These should never be committed. If this is \`.env.example\`, ` +
+ `it still needs explicit confirmation per project policy.`,
+ )
+}
+
+// --- 6. Console.log left behind in source ----------------------------------
+// Lucky uses structured logging via debugLog/infoLog/errorLog/warnLog.
+// Bare console.log in src/ is almost always debug residue.
+async function checkConsoleLogs(): Promise {
+ const sourceTouched = all.filter(
+ (p) => /^packages\/.+\/src\//.test(p) && /\.(ts|tsx|js)$/.test(p),
+ )
+ for (const file of sourceTouched) {
+ const diff = await danger.git.diffForFile(file)
+ if (!diff) continue
+ // Danger's TextDiff.added is the raw added content (no `+` prefix).
+ const addedLines = diff.added
+ .split('\n')
+ .filter((l) => l.trim().length > 0)
+ .filter((l) => /\bconsole\.(log|debug)\b/.test(l))
+ if (addedLines.length > 0) {
+ warn(
+ `Possible debug residue in \`${file}\`: ${addedLines.length} \`console.log/debug\` ` +
+ `line(s) added. Use \`debugLog\` from \`@lucky/shared/utils\` for structured logging.`,
+ )
+ }
+ }
+}
+
+// --- 7. Branch-prefix discipline -------------------------------------------
+// Per workflow.md: feature/, fix/, refactor/, chore/, docs/, ci/, test/, release/
+const headRef = danger.github.pr.head.ref
+const validPrefixes =
+ /^(feature|feat|fix|refactor|chore|docs|ci|test|release|hotfix|dependabot|perf|style)\//
+if (!validPrefixes.test(headRef) && !headRef.startsWith('worktree-')) {
+ warn(
+ `Branch \`${headRef}\` doesn't follow the standard prefix convention ` +
+ `(\`feature/\`, \`fix/\`, \`refactor/\`, \`chore/\`, \`docs/\`, \`ci/\`, \`test/\`, \`release/\`).`,
+ )
+}
+
+// --- 8. Big-file warning ----------------------------------------------------
+// Files > 500 lines are review-hostile. Flag new ones.
+async function checkLargeFiles(): Promise {
+ for (const file of created) {
+ const lineCount = (await danger.git.structuredDiffForFile(file))?.chunks
+ ?.flatMap((c) => c.changes)
+ .filter((c) => c.type === 'add').length
+ if (lineCount && lineCount > 500) {
+ warn(
+ `New file \`${file}\` is **${lineCount} lines** — consider splitting.`,
+ )
+ }
+ }
+}
+
+// --- Async runner -----------------------------------------------------------
+// Top-level await ensures Danger waits for these checks before exiting.
+// Without this, fire-and-forget `void checkX()` calls could be cut off
+// when the runtime tears down, silently dropping warnings.
+async function runAsyncChecks(): Promise {
+ const sourceAdditions = await countSourceAdditions()
+ if (
+ sourceChanges.length > 0 &&
+ testChanges.length === 0 &&
+ sourceAdditions > 50
+ ) {
+ warn(
+ `**No test changes** despite ${sourceChanges.length} source files modified ` +
+ `(${sourceAdditions} source lines added). ` +
+ `If this is intentional (e.g. refactor, build config), reply explaining why.`,
+ )
+ }
+
+ await Promise.all([checkConsoleLogs(), checkLargeFiles()])
+}
+
+// Hand the async work to Danger's scheduler. Danger awaits any promises
+// registered via schedule() before exit. Using schedule() instead of a
+// top-level await keeps the dangerfile CommonJS-loadable (Danger v12 uses
+// require(), which rejects top-level await with ERR_REQUIRE_ASYNC_MODULE).
+schedule(runAsyncChecks())
diff --git a/docs/review-tools.md b/docs/review-tools.md
new file mode 100644
index 000000000..1c70faa04
--- /dev/null
+++ b/docs/review-tools.md
@@ -0,0 +1,105 @@
+# Code Review Tooling — Lucky
+
+Per the shared merge rule (workflow.md in the org-level standards, mirrored
+into each contributor's environment), a PR is merge-eligible when CI is green
+AND code review tools have approved. This page documents what each tool does,
+what it doesn't, and how to interpret silence.
+
+## Active stack (currently enforced)
+
+| Tool | Type | What it covers | Cost | Rate limit |
+|------|------|----------------|------|-----------|
+| **CodeRabbit** | AI | Logic review, semver, contextual feedback | Trial / paid | Hits trial cap |
+| **SonarCloud** | SAST + metrics | Quality gate, security hotspots, code smells | Free for public repos | Generous |
+| **GitGuardian** | Secret scan | Leaked credentials | Free for OSS | Generous |
+| **Socket** | Supply-chain | Dependency typosquatting, malware | Free | Per-PR |
+| **TruffleHog** | Secret scan | Git history secrets | Free | Generous |
+| **Danger** | Deterministic | PR convention rules (lockfile, console.log, …) | Free, OSS | None |
+| **Claude review** | AI | Self-owned reviewer for substantive concerns | Anthropic API (~$0.10/PR) | Pay-as-you-go |
+
+## Retired / not gating
+
+| Tool | Status |
+|------|--------|
+| **Greptile** | Trial cap reached (50 reviews/lifetime). Posts may still appear but **do not gate merges**. Use Claude review for the same coverage. |
+
+## Why we replaced Greptile + tightened CodeRabbit (2026-05-10)
+
+Greptile's free tier capped at 50 reviews/lifetime; we hit it. Their reviews
+were valuable when they fired (caught the `fetch()`-doesn't-throw bug on PR
+\#808 that CodeRabbit missed) but unreliable above the cap.
+
+CodeRabbit's default `assertive` profile generated `🟡 Minor / 💤 Low value /
+Nitpick` comments that flipped PRs to `CHANGES_REQUESTED`, blocking the merge
+gate on opinion. Switched to `chill` profile so only substantive issues block.
+
+Replaced both gaps with:
+
+1. **`.coderabbit.yaml`** with `profile: chill` — fewer nits, same bug-finding.
+2. **Claude review action** — self-owned Sonnet-powered reviewer focused on
+ correctness/security/semver/prod-risk. The runtime lives as a reusable
+ workflow in `LucasSantana-Dev/.github`; this repo's
+ `.github/workflows/review-tools.yml` calls it pinned at `@v1`.
+3. **Danger.js** — deterministic rules in `dangerfile.ts` (lockfile drift,
+ console.log residue, missing CHANGELOG, .env leaks, branch naming,
+ big PR warning). The runtime is also a reusable workflow in
+ `LucasSantana-Dev/.github`; rules are repo-specific by design.
+
+**Why one caller workflow + reusable workflows in `LucasSantana-Dev/.github`?**
+See ADR `ai-dev-toolkit:docs/decisions/2026-05-10-multi-repo-review-tools-rollout.md`.
+TL;DR: action SHA bumps and prompt tuning propagate centrally; repo-specific
+dangerfile rules stay local.
+
+## Reading the merge rule against this stack
+
+A PR is merge-eligible when ALL hold:
+
+- ✅ Required CI green
+- ✅ SonarCloud Quality Gate `passed`
+- ✅ CodeRabbit has no `🔴 Critical` / `🟠 Major` unresolved threads
+ (Minor/Nitpick/Optional/Low-value are now summarized, not threaded)
+- ✅ Claude review approved or no substantive concerns posted
+- ✅ Danger has no `fail()` outputs (warnings are fine)
+- ✅ GitGuardian / Socket / TruffleHog clean
+
+When CodeRabbit is rate-limited (rare with `chill` profile) or Greptile is
+absent, **Claude review still runs** — that's the point. No more silent
+gate-bailout.
+
+## Tools considered and skipped
+
+| Tool | Why skipped |
+|------|-------------|
+| Qodana | Strong inspections but JetBrains-specific; ESLint + tsc + ruff already cover the same ground |
+| SonarQube self-hosted | SonarCloud already gives us the quality gate without ops burden |
+| Codacy | Paid; overlaps with SonarCloud + Semgrep |
+| DeepCode (now Snyk Code) | Paid; replaced by Semgrep + GitGuardian + Socket + Danger combo |
+| **Reviewdog** | Mature framework (~9k stars) for posting linter/static-analyzer output as PR comments. Pairs naturally with ESLint, Semgrep, ruff, golangci-lint, custom analyzers. ESLint annotations already surface in CI logs but Reviewdog gets them inline on the diff — better signal at the moment of review. Currently deferred (not in this PR) because Lucky's ESLint is wired through GitHub's native annotations and adding Reviewdog now would duplicate output. Revisit if we add Semgrep custom rules or want unified inline-comment formatting across multiple linters. |
+| **Sourcebot** | Newer (2026) self-hosted tool with whole-codebase indexing + semantic search + AI review. Strong "context awareness" pitch but young — wait for it to mature past v1.0 before adopting. |
+| **Continue** | VS Code / JetBrains plugin for inline AI review during editing. Editor-side, not PR-side — out of scope here, but worth installing locally if you want pre-commit AI feedback. |
+| Trivy | Container scanning; not yet relevant — Lucky deploys via Docker but Socket + GitHub Dependabot already handle node deps |
+| **Qodo Merge** (formerly PR-Agent) | Strong open-source AI reviewer with `/review` `/describe` slash commands and multi-agent architecture. Deferred — Claude review covers the same ground without an extra service to maintain. **Revisit if** we want PR slash-command interactivity, or want to swap LLMs per task (cheap model for `/describe`, expensive for `/review`), or need offline review via local Ollama. |
+| **LucidShark** | CLI-first local SAST + linting, Apache 2.0. Strong privacy story but overlaps with Semgrep + GitGuardian; deferred. |
+| **Tabby** | Self-hosted Copilot alternative with repo-context indexing; primarily a code-completion tool, not a PR reviewer. Out of scope here. |
+| **SonarQube Community Edition (self-hosted)** | SonarCloud already gives us the quality gate without ops burden. The 2026 "AI CodeFix" feature in CE is interesting but not enough to justify hosting. |
+| **CodeFactor / Aikido** | Free-for-OSS SaaS; overlap with SonarCloud + Socket. No clear marginal value. |
+
+The broader Forge-Space-era audit (2026-03-07) compared 20+ free OSS reviewers
+and recorded the rejection rationale per tool; that audit lives in private
+maintainer notes and is summarized in the table above.
+
+## Maintenance
+
+- Re-evaluate Greptile if they introduce per-org pricing
+- Tune `.coderabbit.yaml` if `chill` proves too quiet — `path_filters` or
+ `auto_review.base_branches` are the levers
+- Update the centralized Claude review prompt in `LucasSantana-Dev/.github`
+ (`.github/workflows/claude-review.yml`, referenced by
+ `.github/workflows/review-tools.yml@v1`) when the `workflow.md` merge rule
+ evolves — that's the single source of truth for the review prompt
+- `dangerfile.ts` is the place to encode any new convention that humans keep
+ forgetting — cheaper than re-asking AI bots to remember it
+
+## Reality check on AI reviewers
+
+Even the best AI reviewers (Claude, CodeRabbit, Qodo Merge, Greptile) hallucinate and miss subtle logic bugs. Treat them as **extra reviewers**, not human replacements. The merge rule's "code review tools approved" clause is necessary but not sufficient — a substantive concern from any source (human, bot, or your own gut) still outranks a green checkmark.
diff --git a/package-lock.json b/package-lock.json
index d53c72857..84068c0a8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "lucky-bot",
- "version": "2.9.0",
+ "version": "2.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lucky-bot",
- "version": "2.9.0",
+ "version": "2.10.0",
"license": "ISC",
"workspaces": [
"packages/*"
@@ -22,24 +22,24 @@
"minipass-flush": "^2.0.0"
},
"devDependencies": {
- "@commitlint/cli": "^20.5.3",
- "@commitlint/config-conventional": "^20.5.3",
+ "@commitlint/cli": "^21.0.0",
+ "@commitlint/config-conventional": "^21.0.0",
"@eslint/js": "^10.0.1",
- "@secretlint/secretlint-rule-preset-recommend": "^12.3.1",
+ "@secretlint/secretlint-rule-preset-recommend": "^13.0.0",
"@types/jest": "^30.0.0",
- "@typescript-eslint/eslint-plugin": "^8.59.1",
+ "@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.54.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.6.0",
- "jest": "^30.2.0",
+ "jest": "^30.4.2",
"prettier": "^3.8.3",
"prisma": "^7.8.0",
- "secretlint": "^12.3.1",
+ "secretlint": "^13.0.0",
"ts-jest": "^29.4.9",
"unfetch": "^5.0.0"
},
"engines": {
- "node": "22.x"
+ "node": ">=22 <27"
}
},
"node_modules/@adobe/css-tools": {
@@ -1463,7 +1463,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1473,7 +1473,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1507,7 +1507,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
@@ -1805,7 +1805,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -1874,134 +1874,233 @@
}
},
"node_modules/@commitlint/cli": {
- "version": "20.5.3",
- "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.3.tgz",
- "integrity": "sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.0.tgz",
+ "integrity": "sha512-p3y2oC0G2R45zaadMwBxCiSesS8digi5RDplP3Zrfpzm7xIgrgAj0W4fGzONjpHyg8obDVJDU45g5txzeMcblg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/format": "^20.5.0",
- "@commitlint/lint": "^20.5.3",
- "@commitlint/load": "^20.5.3",
- "@commitlint/read": "^20.5.0",
- "@commitlint/types": "^20.5.0",
+ "@commitlint/format": "^21.0.0",
+ "@commitlint/lint": "^21.0.0",
+ "@commitlint/load": "^21.0.0",
+ "@commitlint/read": "^21.0.0",
+ "@commitlint/types": "^21.0.0",
"tinyexec": "^1.0.0",
- "yargs": "^17.0.0"
+ "yargs": "^18.0.0"
},
"bin": {
"commitlint": "cli.js"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/cli/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": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@commitlint/cli/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/yargs": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^7.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/@commitlint/config-conventional": {
- "version": "20.5.3",
- "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.3.tgz",
- "integrity": "sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.0.tgz",
+ "integrity": "sha512-QJX/rPK4Yu3f5J4OCIBy5aXq2e0EEdwSDFZ3NQvFAXTm3gs12ipyZ+yjhZxm3hHn6DB8wuv3zhFTL1I2tYzUBA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/types": "^20.5.0",
+ "@commitlint/types": "^21.0.0",
"conventional-changelog-conventionalcommits": "^9.2.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/config-validator": {
- "version": "20.5.0",
- "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz",
- "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.0.0.tgz",
+ "integrity": "sha512-v0UplTYryNUB463X5WrelzKq5/qyYm9/iUNk38S7ZLnd56Uuk2T9awhYKGlgD2/4L5YuN2gsKkyy4EHpRPPz2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/types": "^20.5.0",
+ "@commitlint/types": "^21.0.0",
"ajv": "^8.11.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/ensure": {
- "version": "20.5.3",
- "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.3.tgz",
- "integrity": "sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.0.0.tgz",
+ "integrity": "sha512-n+OYs0Ws9GKC2WlmAeLNoPz9CUg6n/ZyYMkFF8rJ0aMn2kDTDTG0VqK/2Dco0EB4fhuF3JPIllJmU9/LKTl4aw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/types": "^20.5.0",
+ "@commitlint/types": "^21.0.0",
"es-toolkit": "^1.46.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/execute-rule": {
- "version": "20.0.0",
- "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz",
- "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.0.tgz",
+ "integrity": "sha512-3OhTq2gQX1tEheMsbDNqxfcNHsAM6g9cub9plf05I9jCxtbNfn8Y+mhClKyUwhX4dbtmC4OLZ9i+HNmoL1aksA==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/format": {
- "version": "20.5.0",
- "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz",
- "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.0.0.tgz",
+ "integrity": "sha512-RTfGSrueEgofs1piqwi42U05d85wfxiMH2ncMCZnltx1XqPR3N2S48oACBtTy4xRAhWlf5XlHkK2RaDzEQu3dA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/types": "^20.5.0",
+ "@commitlint/types": "^21.0.0",
"picocolors": "^1.1.1"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/is-ignored": {
- "version": "20.5.0",
- "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz",
- "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.0.0.tgz",
+ "integrity": "sha512-K3SaaOTVY9VKhge7vl0R3ng7GENRzJQ9MPV43Tu53kAwEgSx/E0HF4US3AcVqdvlvsDUbF2yXvED95dhela83w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/types": "^20.5.0",
+ "@commitlint/types": "^21.0.0",
"semver": "^7.6.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/lint": {
- "version": "20.5.3",
- "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.3.tgz",
- "integrity": "sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.0.0.tgz",
+ "integrity": "sha512-dlUJA0Ka14R1YaR46JVRWE3m/8dOQAgE/D0heUfzYua5Jogtq/zzu2ITAIaB/u25DaKjtEO6kuvASzsFDyrPMw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/is-ignored": "^20.5.0",
- "@commitlint/parse": "^20.5.0",
- "@commitlint/rules": "^20.5.3",
- "@commitlint/types": "^20.5.0"
+ "@commitlint/is-ignored": "^21.0.0",
+ "@commitlint/parse": "^21.0.0",
+ "@commitlint/rules": "^21.0.0",
+ "@commitlint/types": "^21.0.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/load": {
- "version": "20.5.3",
- "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.3.tgz",
- "integrity": "sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.0.0.tgz",
+ "integrity": "sha512-l0nBfO/20PKcJXHZqDIgh7kw/TWVVwn8zZJOkVGBK/ig/h328jBu9jK7OiDl2oZr5mLphmKGjYDR2ffEyb2lIA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/config-validator": "^20.5.0",
- "@commitlint/execute-rule": "^20.0.0",
- "@commitlint/resolve-extends": "^20.5.3",
- "@commitlint/types": "^20.5.0",
+ "@commitlint/config-validator": "^21.0.0",
+ "@commitlint/execute-rule": "^21.0.0",
+ "@commitlint/resolve-extends": "^21.0.0",
+ "@commitlint/types": "^21.0.0",
"cosmiconfig": "^9.0.1",
"cosmiconfig-typescript-loader": "^6.1.0",
"es-toolkit": "^1.46.0",
@@ -2009,112 +2108,110 @@
"picocolors": "^1.1.1"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/message": {
- "version": "20.4.3",
- "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz",
- "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.0.0.tgz",
+ "integrity": "sha512-+daU92JaOHhI2En9KcH+2mvZGJ6D4YSxb/32QDwqkOwSj1Vanjio8PbAqX7dneACdg6B7RgQ7i3mpyYZAws4nw==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/parse": {
- "version": "20.5.0",
- "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz",
- "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.0.0.tgz",
+ "integrity": "sha512-1dbvFBcQK79aTbpc2QCrgEDc6/MMkQ0Mdz4gGmYkN4AHMnAK9HesSewTHqGTrW5mALrMlYSgcWyvKjloY2w19A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/types": "^20.5.0",
+ "@commitlint/types": "^21.0.0",
"conventional-changelog-angular": "^8.2.0",
"conventional-commits-parser": "^6.3.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/read": {
- "version": "20.5.0",
- "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz",
- "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.0.0.tgz",
+ "integrity": "sha512-8VKLKLl2vBSKoTMm1LwcySsyxrBeotnqcT5qJi9pPuPfqSapdAD870Ckgh79c41UFywL6kMqtiyY+kxtfcqZGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/top-level": "^20.4.3",
- "@commitlint/types": "^20.5.0",
+ "@commitlint/top-level": "^21.0.0",
+ "@commitlint/types": "^21.0.0",
"git-raw-commits": "^5.0.0",
- "minimist": "^1.2.8",
"tinyexec": "^1.0.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/resolve-extends": {
- "version": "20.5.3",
- "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.3.tgz",
- "integrity": "sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.0.0.tgz",
+ "integrity": "sha512-hrJYSZRpmecmSoxYrpuJ/1Q4J9JHt4AVVtr5/Ac6upLO/jJ1DnIm2AjD+38gru3KGOec4aHCVqETuWWLJhydWw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/config-validator": "^20.5.0",
- "@commitlint/types": "^20.5.0",
+ "@commitlint/config-validator": "^21.0.0",
+ "@commitlint/types": "^21.0.0",
"es-toolkit": "^1.46.0",
"global-directory": "^5.0.0",
- "import-meta-resolve": "^4.0.0",
"resolve-from": "^5.0.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/rules": {
- "version": "20.5.3",
- "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.3.tgz",
- "integrity": "sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.0.0.tgz",
+ "integrity": "sha512-NgQhX1qENA+rbrMw5KKyvVZpZG4D/0wgK8Z4INtcwKbfKtVDFMbn0oNc/Rs8wdyBPBj7ue8Lo/GllUL2Mqjwkg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@commitlint/ensure": "^20.5.3",
- "@commitlint/message": "^20.4.3",
- "@commitlint/to-lines": "^20.0.0",
- "@commitlint/types": "^20.5.0"
+ "@commitlint/ensure": "^21.0.0",
+ "@commitlint/message": "^21.0.0",
+ "@commitlint/to-lines": "^21.0.0",
+ "@commitlint/types": "^21.0.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/to-lines": {
- "version": "20.0.0",
- "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz",
- "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.0.tgz",
+ "integrity": "sha512-qMwvrJK/x3dPcXsIAtQAMKV5Q0wTioyqyHKR06vVN4wmBF4cCrrLq5x81FDeY3Ba+GWgDt0/P3Zw/IHGM8lwgg==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/top-level": {
- "version": "20.4.3",
- "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz",
- "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.0.0.tgz",
+ "integrity": "sha512-8jPqyWZueuN4hU6/ArKVsZ6i8xWtjIrbzHEOaLaTGUfjhhbZNBfXef/DGjzxy55hAv3yFNxHLINfI1bCJ0/MzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@commitlint/types": {
- "version": "20.5.0",
- "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz",
- "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.0.0.tgz",
+ "integrity": "sha512-6nEz+M7I90iix4sviA8NLwskOuyt0M98KUU2aYgiKbn46jMSxUm1l2ACtzRd9ec+y38aKyJhW4Fp6NW0z35kJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2122,13 +2219,13 @@
"picocolors": "^1.1.1"
},
"engines": {
- "node": ">=v18"
+ "node": ">=22.12.0"
}
},
"node_modules/@conventional-changelog/git-client": {
- "version": "2.6.0",
- "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.6.0.tgz",
- "integrity": "sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==",
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz",
+ "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2141,7 +2238,7 @@
},
"peerDependencies": {
"conventional-commits-filter": "^5.0.0",
- "conventional-commits-parser": "^6.3.0"
+ "conventional-commits-parser": "^6.4.0"
},
"peerDependenciesMeta": {
"conventional-commits-filter": {
@@ -3479,17 +3576,17 @@
}
},
"node_modules/@jest/console": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz",
- "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==",
+ "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": {
- "@jest/types": "30.3.0",
+ "@jest/types": "30.4.1",
"@types/node": "*",
"chalk": "^4.1.2",
- "jest-message-util": "30.3.0",
- "jest-util": "30.3.0",
+ "jest-message-util": "30.4.1",
+ "jest-util": "30.4.1",
"slash": "^3.0.0"
},
"engines": {
@@ -3530,38 +3627,39 @@
}
},
"node_modules/@jest/core": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz",
- "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==",
+ "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": {
- "@jest/console": "30.3.0",
- "@jest/pattern": "30.0.1",
- "@jest/reporters": "30.3.0",
- "@jest/test-result": "30.3.0",
- "@jest/transform": "30.3.0",
- "@jest/types": "30.3.0",
+ "@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.3.0",
- "jest-config": "30.3.0",
- "jest-haste-map": "30.3.0",
- "jest-message-util": "30.3.0",
- "jest-regex-util": "30.0.1",
- "jest-resolve": "30.3.0",
- "jest-resolve-dependencies": "30.3.0",
- "jest-runner": "30.3.0",
- "jest-runtime": "30.3.0",
- "jest-snapshot": "30.3.0",
- "jest-util": "30.3.0",
- "jest-validate": "30.3.0",
- "jest-watcher": "30.3.0",
- "pretty-format": "30.3.0",
+ "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": {
@@ -3576,6 +3674,19 @@
}
}
},
+ "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": {
+ "@sinclair/typebox": "^0.34.0"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
"node_modules/@jest/core/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3610,31 +3721,25 @@
}
},
"node_modules/@jest/core/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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.0.5",
+ "@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "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/@jest/core/node_modules/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/@jest/diff-sequences": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz",
- "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==",
+ "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": {
@@ -3642,39 +3747,39 @@
}
},
"node_modules/@jest/environment": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz",
- "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==",
+ "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.3.0",
- "@jest/types": "30.3.0",
+ "@jest/fake-timers": "30.4.1",
+ "@jest/types": "30.4.1",
"@types/node": "*",
- "jest-mock": "30.3.0"
+ "jest-mock": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/expect": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz",
- "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==",
+ "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": {
- "expect": "30.3.0",
- "jest-snapshot": "30.3.0"
+ "expect": "30.4.1",
+ "jest-snapshot": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/expect-utils": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz",
- "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==",
+ "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": {
@@ -3685,18 +3790,18 @@
}
},
"node_modules/@jest/fake-timers": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz",
- "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==",
+ "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.3.0",
- "@sinonjs/fake-timers": "^15.0.0",
+ "@jest/types": "30.4.1",
+ "@sinonjs/fake-timers": "^15.4.0",
"@types/node": "*",
- "jest-message-util": "30.3.0",
- "jest-mock": "30.3.0",
- "jest-util": "30.3.0"
+ "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"
@@ -3713,47 +3818,47 @@
}
},
"node_modules/@jest/globals": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz",
- "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==",
+ "version": "30.4.1",
+ "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz",
+ "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@jest/environment": "30.3.0",
- "@jest/expect": "30.3.0",
- "@jest/types": "30.3.0",
- "jest-mock": "30.3.0"
+ "@jest/environment": "30.4.1",
+ "@jest/expect": "30.4.1",
+ "@jest/types": "30.4.1",
+ "jest-mock": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/pattern": {
- "version": "30.0.1",
- "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz",
- "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==",
+ "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": {
"@types/node": "*",
- "jest-regex-util": "30.0.1"
+ "jest-regex-util": "30.4.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/reporters": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz",
- "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==",
+ "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": {
"@bcoe/v8-coverage": "^0.2.3",
- "@jest/console": "30.3.0",
- "@jest/test-result": "30.3.0",
- "@jest/transform": "30.3.0",
- "@jest/types": "30.3.0",
+ "@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",
@@ -3766,9 +3871,9 @@
"istanbul-lib-report": "^3.0.0",
"istanbul-lib-source-maps": "^5.0.0",
"istanbul-reports": "^3.1.3",
- "jest-message-util": "30.3.0",
- "jest-util": "30.3.0",
- "jest-worker": "30.3.0",
+ "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"
@@ -3832,13 +3937,13 @@
}
},
"node_modules/@jest/snapshot-utils": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz",
- "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==",
+ "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": {
- "@jest/types": "30.3.0",
+ "@jest/types": "30.4.1",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"natural-compare": "^1.4.0"
@@ -3896,14 +4001,14 @@
}
},
"node_modules/@jest/test-result": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz",
- "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==",
+ "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.3.0",
- "@jest/types": "30.3.0",
+ "@jest/console": "30.4.1",
+ "@jest/types": "30.4.1",
"@types/istanbul-lib-coverage": "^2.0.6",
"collect-v8-coverage": "^1.0.2"
},
@@ -3912,15 +4017,15 @@
}
},
"node_modules/@jest/test-sequencer": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz",
- "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==",
+ "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": {
- "@jest/test-result": "30.3.0",
+ "@jest/test-result": "30.4.1",
"graceful-fs": "^4.2.11",
- "jest-haste-map": "30.3.0",
+ "jest-haste-map": "30.4.1",
"slash": "^3.0.0"
},
"engines": {
@@ -3928,23 +4033,23 @@
}
},
"node_modules/@jest/transform": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz",
- "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==",
+ "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.3.0",
+ "@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.3.0",
- "jest-regex-util": "30.0.1",
- "jest-util": "30.3.0",
+ "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"
@@ -3987,14 +4092,14 @@
}
},
"node_modules/@jest/types": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz",
- "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==",
+ "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.0.1",
- "@jest/schemas": "30.0.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": "*",
@@ -4005,6 +4110,19 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
+ "node_modules/@jest/types/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/types/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -4735,44 +4853,6 @@
"license": "MIT",
"optional": true
},
- "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==",
- "dev": true,
- "license": "MIT",
- "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==",
- "dev": true,
- "license": "MIT",
- "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==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/@npmcli/agent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz",
@@ -5315,9 +5395,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.127.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
- "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
+ "version": "0.128.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
+ "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -7576,9 +7656,9 @@
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
"cpu": [
"arm64"
],
@@ -7593,9 +7673,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
"cpu": [
"arm64"
],
@@ -7610,9 +7690,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
- "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
"cpu": [
"x64"
],
@@ -7627,9 +7707,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
- "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
"cpu": [
"x64"
],
@@ -7644,9 +7724,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
- "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
+ "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
"cpu": [
"arm"
],
@@ -7661,9 +7741,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
"cpu": [
"arm64"
],
@@ -7678,9 +7758,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
- "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
+ "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
"cpu": [
"arm64"
],
@@ -7695,9 +7775,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
"cpu": [
"ppc64"
],
@@ -7712,9 +7792,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
"cpu": [
"s390x"
],
@@ -7729,9 +7809,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
"cpu": [
"x64"
],
@@ -7746,9 +7826,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
- "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
+ "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
"cpu": [
"x64"
],
@@ -7763,9 +7843,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
"cpu": [
"arm64"
],
@@ -7780,9 +7860,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
- "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
+ "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
"cpu": [
"wasm32"
],
@@ -7852,9 +7932,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
- "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
+ "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
"cpu": [
"arm64"
],
@@ -7869,9 +7949,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
- "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
+ "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
"cpu": [
"x64"
],
@@ -8258,29 +8338,29 @@
"license": "MIT"
},
"node_modules/@secretlint/config-creator": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-12.3.1.tgz",
- "integrity": "sha512-CCRvPfrQLt2fPg3eWTIDGXNcVFQd6ZnvQCZ5lzclV9OF7iRqXQ4l5lfGO8NS68tIZx7YvBKhcO8/eVxdqm89HA==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-13.0.0.tgz",
+ "integrity": "sha512-HJLoVUqXPSxu1s7b2TSjCIeScOHxD4hvPYysJUOqnKqEs/ZqXwC9uFpRo8qS79cVwe8s4lF+OoLmXnMCyMMnxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@secretlint/types": "12.3.1"
+ "@secretlint/types": "13.0.0"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@secretlint/config-loader": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-12.3.1.tgz",
- "integrity": "sha512-PNrxz8tnAU/y5PmfOtKfVb+zEA3I+1iZqP1f9fXvIBtauBKs0h0Y+Cmvj0gG1a34kxaD1aQvFh8qHEhRV05gWg==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-13.0.0.tgz",
+ "integrity": "sha512-fWRE9aMZ4AHzDNaJNKX1P0W1IS08fFLe0c17jKq8BYhrlCehl2II6HqQXmef5no92SBpAEMLuL0uVAdfrtdigQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@secretlint/profiler": "12.3.1",
- "@secretlint/resolver": "12.3.1",
- "@secretlint/types": "12.3.1",
- "ajv": "^8.18.0",
+ "@secretlint/profiler": "13.0.0",
+ "@secretlint/resolver": "13.0.0",
+ "@secretlint/types": "13.0.0",
+ "ajv": "^8.20.0",
"debug": "^4.4.3",
"rc-config-loader": "^4.1.4"
},
@@ -8289,14 +8369,14 @@
}
},
"node_modules/@secretlint/core": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-12.3.1.tgz",
- "integrity": "sha512-ulcfARo1TANr8tWzDO/5cFxSNEEfRzgW6YPHYUijgpH3iYfwtUhWEU/r/BiFGl2PNaGzzVE1N9A6nZ74xbYvUQ==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-13.0.0.tgz",
+ "integrity": "sha512-j7Yt1o7sgFsN19N0Q4CGmLOdvf0PG3DE5xMYAoQ42i+h4/7Ui3HfCKcDAycDp4aXDiJAJvluko64GY0yDobAVg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@secretlint/profiler": "12.3.1",
- "@secretlint/types": "12.3.1",
+ "@secretlint/profiler": "13.0.0",
+ "@secretlint/types": "13.0.0",
"debug": "^4.4.3",
"structured-source": "^4.0.0"
},
@@ -8305,17 +8385,17 @@
}
},
"node_modules/@secretlint/formatter": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-12.3.1.tgz",
- "integrity": "sha512-SXpTiRzuuFNbHa59zk0eUxFOB/LYxnuHSfqq7zU9lIt0z5rox6NrnN9WWkoQai2V9s7n3VqUVUpZqnhxQ2Jzpw==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-13.0.0.tgz",
+ "integrity": "sha512-OOskMV3r7yJQFo+0PvrRalRYKwMa2kdx9hrMiO9RYshPGn+155084j0uIziburXhdQmRhhrCe+KFln87KHm5pQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@secretlint/resolver": "12.3.1",
- "@secretlint/types": "12.3.1",
- "@textlint/linter-formatter": "^15.5.4",
- "@textlint/module-interop": "^15.5.4",
- "@textlint/types": "^15.5.4",
+ "@secretlint/resolver": "13.0.0",
+ "@secretlint/types": "13.0.0",
+ "@textlint/linter-formatter": "^15.6.0",
+ "@textlint/module-interop": "^15.6.0",
+ "@textlint/types": "^15.6.0",
"chalk": "^5.6.2",
"debug": "^4.4.3",
"pluralize": "^8.0.0",
@@ -8328,18 +8408,18 @@
}
},
"node_modules/@secretlint/node": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-12.3.1.tgz",
- "integrity": "sha512-1T08nqwWIJqSRrfkebk4Op5MwYgNnB6gwjv9v+X+V+HEIeG1GB/EgH8CJa8jK4uYdhUuaKyXpu36FIbjNa1wqA==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-13.0.0.tgz",
+ "integrity": "sha512-256dKkIHB4cXXBT9Cr/tcwf1n3IxCo5sk8Q9z4QXDux+IAUKjRxyk3c02LcJXgTc3GTrZGh+F1s17vUo3qJG+g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@secretlint/config-loader": "12.3.1",
- "@secretlint/core": "12.3.1",
- "@secretlint/formatter": "12.3.1",
- "@secretlint/profiler": "12.3.1",
- "@secretlint/source-creator": "12.3.1",
- "@secretlint/types": "12.3.1",
+ "@secretlint/config-loader": "13.0.0",
+ "@secretlint/core": "13.0.0",
+ "@secretlint/formatter": "13.0.0",
+ "@secretlint/profiler": "13.0.0",
+ "@secretlint/source-creator": "13.0.0",
+ "@secretlint/types": "13.0.0",
"debug": "^4.4.3",
"p-map": "^7.0.4"
},
@@ -8348,23 +8428,23 @@
}
},
"node_modules/@secretlint/profiler": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-12.3.1.tgz",
- "integrity": "sha512-lztyqJPTfkY0Ze9P7vNs3zm7p2Wq1+4ilFXVrxin0sDyFVXpkt+0+vsKsmdx9yBHabxrLDZgxa7fIsfV721cLw==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-13.0.0.tgz",
+ "integrity": "sha512-jQb/UBs0kgbzGGeQ+i4LuSSHvPwscp5Ge9UC4/izxNi3guwBTkE/bsREvbEaGR80qtn8fmWZytP88oWYMMrN2g==",
"dev": true,
"license": "MIT"
},
"node_modules/@secretlint/resolver": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-12.3.1.tgz",
- "integrity": "sha512-/QwcX5azKRdz9mBIbTBUsqp+cmWQZYGNdOHLbsMOBTLXa7KoEBffhmeaMSc0kNSrdgbgfu/7j+qeeaF4QwJf3A==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-13.0.0.tgz",
+ "integrity": "sha512-5mUTxb+CPl/589Efq5L4SBqJ2yDguKNVZ7udCPiQasI9jS0h1P/CBWfqp+eoKlSoEggu2RKbMHdRiidRfnRXQQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@secretlint/secretlint-rule-preset-recommend": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-12.3.1.tgz",
- "integrity": "sha512-w9x9rIP1+qhV0k9k15aSIBo+6NrM4npAYIoNs7UmKIxuQSA6b91rhWpdMxgCv2/EQtym+gpuyN7rTSvG5wlYlw==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-13.0.0.tgz",
+ "integrity": "sha512-aKjoMnyMmQ1wDxLPUH0IdnM7yPOwSopT4ZhWoctthAW7nfYQoLmHKiTI8bUKOGU9n4VfVPG8N84vXmT3sG7d/g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8372,13 +8452,13 @@
}
},
"node_modules/@secretlint/source-creator": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-12.3.1.tgz",
- "integrity": "sha512-RCkmyKdoe6VFWMzzVm5a9W+a+ptJSusVX+YOrcNy/heklMIWLg0bL+HYFcyYCm8rU2dRq2HuSYTOamDjNs0LZg==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-13.0.0.tgz",
+ "integrity": "sha512-+whDtfudstqU0/rt787EDdqvNCna/c2REw9BOHYAcylZ2tpyL35gi0z7byUcqu4aJcI1z5epG7APcM0nd50PGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@secretlint/types": "12.3.1",
+ "@secretlint/types": "13.0.0",
"istextorbinary": "^9.5.0"
},
"engines": {
@@ -8386,11 +8466,25 @@
}
},
"node_modules/@secretlint/types": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-12.3.1.tgz",
- "integrity": "sha512-Qv3fKvPkzUJpS9Ps6m2EPjC0RdxS2ZZrRfZAhIdl2u0zSjgf+Z0+AaCngmHRR+3Vtbw6s2FrCf4T6mLirm8Hgg==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-13.0.0.tgz",
+ "integrity": "sha512-C0tVelPjw8UHNeUgAMMWq85EWEItL+evpICrOezhM+jETYUYTNdnQ8dcEOuFJrevojQBxY2ygeHPiJoqkWPmNA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@secretlint/walker": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/@secretlint/walker/-/walker-13.0.0.tgz",
+ "integrity": "sha512-Q7+yhgKvSUA7dS8J8asO/04JfmvUnQRW9eB9CrgTmGY7m6klAHkgAyNaBw/JAe5ISxQes379su3xxa8TS9AZ/g==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "ignore": "^7.0.5",
+ "picomatch": "^4.0.4"
+ },
"engines": {
"node": ">=22.0.0"
}
@@ -8579,9 +8673,9 @@
}
},
"node_modules/@sinonjs/fake-timers": {
- "version": "15.1.1",
- "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz",
- "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==",
+ "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": {
@@ -9572,9 +9666,9 @@
"license": "MIT"
},
"node_modules/@swc/core": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.32.tgz",
- "integrity": "sha512-/eWL0n43D64QWEUHLtTE+jDqjkJhyidjkDhv6f0uJohOUAhywxQ9wXYp845DNNds0JpCdI4Uo0a9bl+vbXf+ew==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz",
+ "integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -9590,18 +9684,18 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
- "@swc/core-darwin-arm64": "1.15.32",
- "@swc/core-darwin-x64": "1.15.32",
- "@swc/core-linux-arm-gnueabihf": "1.15.32",
- "@swc/core-linux-arm64-gnu": "1.15.32",
- "@swc/core-linux-arm64-musl": "1.15.32",
- "@swc/core-linux-ppc64-gnu": "1.15.32",
- "@swc/core-linux-s390x-gnu": "1.15.32",
- "@swc/core-linux-x64-gnu": "1.15.32",
- "@swc/core-linux-x64-musl": "1.15.32",
- "@swc/core-win32-arm64-msvc": "1.15.32",
- "@swc/core-win32-ia32-msvc": "1.15.32",
- "@swc/core-win32-x64-msvc": "1.15.32"
+ "@swc/core-darwin-arm64": "1.15.33",
+ "@swc/core-darwin-x64": "1.15.33",
+ "@swc/core-linux-arm-gnueabihf": "1.15.33",
+ "@swc/core-linux-arm64-gnu": "1.15.33",
+ "@swc/core-linux-arm64-musl": "1.15.33",
+ "@swc/core-linux-ppc64-gnu": "1.15.33",
+ "@swc/core-linux-s390x-gnu": "1.15.33",
+ "@swc/core-linux-x64-gnu": "1.15.33",
+ "@swc/core-linux-x64-musl": "1.15.33",
+ "@swc/core-win32-arm64-msvc": "1.15.33",
+ "@swc/core-win32-ia32-msvc": "1.15.33",
+ "@swc/core-win32-x64-msvc": "1.15.33"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -9613,13 +9707,12 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.32.tgz",
- "integrity": "sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz",
+ "integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==",
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9630,13 +9723,12 @@
}
},
"node_modules/@swc/core-darwin-x64": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.32.tgz",
- "integrity": "sha512-KOTXJXdAhWL+hZ77MYP3z+4pcMFaQhQ74yqyN1uz093q0YnbxpqMtYpPISbYvMHzVRNNx5kN+9RZAXEaadhWVA==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz",
+ "integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==",
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9647,13 +9739,12 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.32.tgz",
- "integrity": "sha512-oOoxLweljlc0A4X8ybsgxV7cVaYTwBOg2iMDJcFR3Sr48C+lsv9VzSmqdK/IVIXF4W4GjLc3VqTAdSMXlfVLuQ==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz",
+ "integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==",
"cpu": [
"arm"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -9664,13 +9755,12 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.32.tgz",
- "integrity": "sha512-oDzEkdl6D6BAWdMtU5KGO7y3HR5fJcvByNLyEk9+ugj8nP5Ovb7P4kBcStBXc4MPExFGQryehiINMlmY8HlclA==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz",
+ "integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==",
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9681,13 +9771,12 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.32.tgz",
- "integrity": "sha512-omcqjoZP/b8D8PuczVoRwJieC6ibj7qIxTftNYokz4/aSmKFHvsd7nIFfPk5ZvtzncbH4AY7+Dkr/Lp2gWxYeA==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz",
+ "integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==",
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9698,13 +9787,12 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.32.tgz",
- "integrity": "sha512-KGkTMyz/Tbn3PBNu0AVZ4GTDFKnICrYcTiNPZq8DrvK42pnFsf3GNDrIG9E5AtQlTmC0YigkWKmu0eMcfTrmgA==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz",
+ "integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==",
"cpu": [
"ppc64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9715,13 +9803,12 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.32.tgz",
- "integrity": "sha512-G3Aa4tVS/3OGZBkoNIwUF9F6RAy+Osb4GOlo62SinLmDiErz/ykmM7KH0wkz6l9kM8jJq1HyAM6atJTUEbBk7g==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz",
+ "integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==",
"cpu": [
"s390x"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9732,13 +9819,12 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.32.tgz",
- "integrity": "sha512-ERsjfGcj6CBmj3vJnGDO8m8rTvw6RqMcWo1dogOtNx3/+/0+NNpJiXDobJrr1GwInI/BHAEkvSFIH6d2LqPcUQ==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz",
+ "integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==",
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9749,13 +9835,12 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.32.tgz",
- "integrity": "sha512-N4Ggahe/8SUbTX50P6EdhbW9YWcgbZVb52R4cq6MK+zsoMjRq7rGvV5ztA05QnbaCYqMYx8rTY7KAIA3Crdo4Q==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz",
+ "integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==",
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9766,13 +9851,12 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.32.tgz",
- "integrity": "sha512-01yN0o9jvo8xBTP12aPK2wW8b41jmOlGbDDlAnoynotc4pO6xA0zby9f1z6j++qXDpGBttLySq1omgVrlQKYcw==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz",
+ "integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==",
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9783,13 +9867,12 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.32.tgz",
- "integrity": "sha512-fLagI9XZYNpTcmlqAcp3KBtmj7E19WCmYD80Jxj1Kn5tGNa7yxNLd3NNdWxuZGUPl5iC0/KqZru7g08gF6Fsrw==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz",
+ "integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==",
"cpu": [
"ia32"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9800,13 +9883,12 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
- "version": "1.15.32",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.32.tgz",
- "integrity": "sha512-gbc2bQ/T2CiR+w0OvcVKwLOFAcPZBvmWmolbwpg1E8UrpeC03DGtyMUApOHNXNYWA3SHFrYXCQtosrcMza1YFg==",
+ "version": "1.15.33",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz",
+ "integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==",
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -9834,49 +9916,49 @@
}
},
"node_modules/@tailwindcss/node": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
- "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
+ "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
- "enhanced-resolve": "^5.19.0",
+ "enhanced-resolve": "^5.21.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
- "tailwindcss": "4.2.4"
+ "tailwindcss": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
- "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
+ "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.2.4",
- "@tailwindcss/oxide-darwin-arm64": "4.2.4",
- "@tailwindcss/oxide-darwin-x64": "4.2.4",
- "@tailwindcss/oxide-freebsd-x64": "4.2.4",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
- "@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
- "@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
- "@tailwindcss/oxide-linux-x64-musl": "4.2.4",
- "@tailwindcss/oxide-wasm32-wasi": "4.2.4",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
- "@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
+ "@tailwindcss/oxide-android-arm64": "4.3.0",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.0",
+ "@tailwindcss/oxide-darwin-x64": "4.3.0",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.0",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.0",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.0",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
- "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
+ "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
"cpu": [
"arm64"
],
@@ -9891,9 +9973,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
- "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
+ "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
"cpu": [
"arm64"
],
@@ -9908,9 +9990,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
- "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
+ "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
"cpu": [
"x64"
],
@@ -9925,9 +10007,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
- "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
+ "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
"cpu": [
"x64"
],
@@ -9942,9 +10024,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
- "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
+ "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
"cpu": [
"arm"
],
@@ -9959,9 +10041,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
- "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
+ "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
"cpu": [
"arm64"
],
@@ -9976,9 +10058,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
- "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
+ "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
"cpu": [
"arm64"
],
@@ -9993,9 +10075,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
- "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
+ "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
"cpu": [
"x64"
],
@@ -10010,9 +10092,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
- "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
+ "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
"cpu": [
"x64"
],
@@ -10027,9 +10109,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
- "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
+ "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -10045,10 +10127,10 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.8.1",
- "@emnapi/runtime": "^1.8.1",
- "@emnapi/wasi-threads": "^1.1.0",
- "@napi-rs/wasm-runtime": "^1.1.1",
+ "@emnapi/core": "^1.10.0",
+ "@emnapi/runtime": "^1.10.0",
+ "@emnapi/wasi-threads": "^1.2.1",
+ "@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
@@ -10057,18 +10139,18 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
- "version": "1.8.1",
+ "version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.1.0",
+ "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
- "version": "1.8.1",
+ "version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -10078,7 +10160,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
- "version": "1.1.0",
+ "version": "1.2.1",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -10088,19 +10170,21 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.1",
+ "version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
@@ -10121,9 +10205,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
- "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
+ "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
"cpu": [
"arm64"
],
@@ -10138,9 +10222,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
- "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
+ "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
"cpu": [
"x64"
],
@@ -10155,17 +10239,17 @@
}
},
"node_modules/@tailwindcss/postcss": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz",
- "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz",
+ "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
- "@tailwindcss/node": "4.2.4",
- "@tailwindcss/oxide": "4.2.4",
- "postcss": "^8.5.6",
- "tailwindcss": "4.2.4"
+ "@tailwindcss/node": "4.3.0",
+ "@tailwindcss/oxide": "4.3.0",
+ "postcss": "^8.5.10",
+ "tailwindcss": "4.3.0"
}
},
"node_modules/@tanstack/query-core": {
@@ -10864,9 +10948,9 @@
}
},
"node_modules/@types/three": {
- "version": "0.184.0",
- "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
- "integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
+ "version": "0.184.1",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
+ "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10912,17 +10996,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
- "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
+ "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.59.1",
- "@typescript-eslint/type-utils": "8.59.1",
- "@typescript-eslint/utils": "8.59.1",
- "@typescript-eslint/visitor-keys": "8.59.1",
+ "@typescript-eslint/scope-manager": "8.59.2",
+ "@typescript-eslint/type-utils": "8.59.2",
+ "@typescript-eslint/utils": "8.59.2",
+ "@typescript-eslint/visitor-keys": "8.59.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -10935,22 +11019,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.59.1",
+ "@typescript-eslint/parser": "^8.59.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
- "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz",
+ "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.59.1",
- "@typescript-eslint/types": "8.59.1",
- "@typescript-eslint/typescript-estree": "8.59.1",
- "@typescript-eslint/visitor-keys": "8.59.1",
+ "@typescript-eslint/scope-manager": "8.59.2",
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/typescript-estree": "8.59.2",
+ "@typescript-eslint/visitor-keys": "8.59.2",
"debug": "^4.4.3"
},
"engines": {
@@ -10966,14 +11050,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz",
- "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
+ "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.59.1",
- "@typescript-eslint/types": "^8.59.1",
+ "@typescript-eslint/tsconfig-utils": "^8.59.2",
+ "@typescript-eslint/types": "^8.59.2",
"debug": "^4.4.3"
},
"engines": {
@@ -10988,14 +11072,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
- "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
+ "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.1",
- "@typescript-eslint/visitor-keys": "8.59.1"
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/visitor-keys": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -11006,9 +11090,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz",
- "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
+ "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11023,15 +11107,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
- "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz",
+ "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.1",
- "@typescript-eslint/typescript-estree": "8.59.1",
- "@typescript-eslint/utils": "8.59.1",
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/typescript-estree": "8.59.2",
+ "@typescript-eslint/utils": "8.59.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -11048,9 +11132,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
- "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
+ "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11062,16 +11146,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
- "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
+ "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.59.1",
- "@typescript-eslint/tsconfig-utils": "8.59.1",
- "@typescript-eslint/types": "8.59.1",
- "@typescript-eslint/visitor-keys": "8.59.1",
+ "@typescript-eslint/project-service": "8.59.2",
+ "@typescript-eslint/tsconfig-utils": "8.59.2",
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/visitor-keys": "8.59.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -11090,16 +11174,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
- "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz",
+ "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.59.1",
- "@typescript-eslint/types": "8.59.1",
- "@typescript-eslint/typescript-estree": "8.59.1"
+ "@typescript-eslint/scope-manager": "8.59.2",
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/typescript-estree": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -11114,13 +11198,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.59.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
- "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
+ "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.1",
+ "@typescript-eslint/types": "8.59.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -12230,9 +12314,9 @@
}
},
"node_modules/ajv": {
- "version": "8.18.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
- "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -12491,16 +12575,16 @@
}
},
"node_modules/babel-jest": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
- "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==",
+ "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": {
- "@jest/transform": "30.3.0",
+ "@jest/transform": "30.4.1",
"@types/babel__core": "^7.20.5",
"babel-plugin-istanbul": "^7.0.1",
- "babel-preset-jest": "30.3.0",
+ "babel-preset-jest": "30.4.0",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"slash": "^3.0.0"
@@ -12566,9 +12650,9 @@
}
},
"node_modules/babel-plugin-jest-hoist": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz",
- "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==",
+ "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": {
@@ -12606,13 +12690,13 @@
}
},
"node_modules/babel-preset-jest": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz",
- "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==",
+ "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-jest-hoist": "30.3.0",
+ "babel-plugin-jest-hoist": "30.4.0",
"babel-preset-current-node-syntax": "^1.2.0"
},
"engines": {
@@ -12994,19 +13078,6 @@
"node": "18 || 20 || >=22"
}
},
- "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==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fill-range": "^7.1.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -13749,9 +13820,9 @@
}
},
"node_modules/conventional-commits-parser": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz",
- "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==",
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz",
+ "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14480,9 +14551,9 @@
}
},
"node_modules/enhanced-resolve": {
- "version": "5.21.0",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
- "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
+ "version": "5.21.2",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz",
+ "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15011,18 +15082,18 @@
}
},
"node_modules/expect": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz",
- "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==",
+ "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.3.0",
+ "@jest/expect-utils": "30.4.1",
"@jest/get-type": "30.1.0",
- "jest-matcher-utils": "30.3.0",
- "jest-message-util": "30.3.0",
- "jest-mock": "30.3.0",
- "jest-util": "30.3.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"
@@ -15273,36 +15344,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fast-glob": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
- "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
- "dev": true,
- "license": "MIT",
- "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.8"
- },
- "engines": {
- "node": ">=8.6.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==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"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",
@@ -15325,9 +15366,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"devOptional": true,
"funding": [
{
@@ -15379,16 +15420,6 @@
"fxparser": "src/cli/cli.js"
}
},
- "node_modules/fastq": {
- "version": "1.20.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
- "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -15538,19 +15569,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "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==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -15928,6 +15946,19 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-east-asian-width": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -16124,53 +16155,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/globby": {
- "version": "16.2.0",
- "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz",
- "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@sindresorhus/merge-streams": "^4.0.0",
- "fast-glob": "^3.3.3",
- "ignore": "^7.0.5",
- "is-path-inside": "^4.0.0",
- "slash": "^5.1.0",
- "unicorn-magic": "^0.4.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/globby/node_modules/slash": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
- "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/globby/node_modules/unicorn-magic": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz",
- "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/googlevideo": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/googlevideo/-/googlevideo-4.0.4.tgz",
@@ -16385,9 +16369,9 @@
"license": "ISC"
},
"node_modules/hono": {
- "version": "4.12.14",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
- "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
+ "version": "4.12.18",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
+ "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"devOptional": true,
"license": "MIT",
"engines": {
@@ -16682,17 +16666,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/import-meta-resolve": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
- "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -16853,16 +16826,6 @@
"node": ">=0.10.0"
}
},
- "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==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
"node_modules/is-obj": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
@@ -16873,19 +16836,6 @@
"node": ">=8"
}
},
- "node_modules/is-path-inside": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
- "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -17115,16 +17065,16 @@
}
},
"node_modules/jest": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz",
- "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==",
+ "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": {
- "@jest/core": "30.3.0",
- "@jest/types": "30.3.0",
+ "@jest/core": "30.4.2",
+ "@jest/types": "30.4.1",
"import-local": "^3.2.0",
- "jest-cli": "30.3.0"
+ "jest-cli": "30.4.2"
},
"bin": {
"jest": "bin/jest.js"
@@ -17142,14 +17092,14 @@
}
},
"node_modules/jest-changed-files": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz",
- "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==",
+ "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": {
"execa": "^5.1.1",
- "jest-util": "30.3.0",
+ "jest-util": "30.4.1",
"p-limit": "^3.1.0"
},
"engines": {
@@ -17157,29 +17107,29 @@
}
},
"node_modules/jest-circus": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz",
- "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==",
+ "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": {
- "@jest/environment": "30.3.0",
- "@jest/expect": "30.3.0",
- "@jest/test-result": "30.3.0",
- "@jest/types": "30.3.0",
+ "@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.3.0",
- "jest-matcher-utils": "30.3.0",
- "jest-message-util": "30.3.0",
- "jest-runtime": "30.3.0",
- "jest-snapshot": "30.3.0",
- "jest-util": "30.3.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.3.0",
+ "pretty-format": "30.4.1",
"pure-rand": "^7.0.0",
"slash": "^3.0.0",
"stack-utils": "^2.0.6"
@@ -17188,6 +17138,19 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
+ "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": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
"node_modules/jest-circus/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -17222,43 +17185,37 @@
}
},
"node_modules/jest-circus/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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.0.5",
+ "@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "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/jest-circus/node_modules/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/jest-cli": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz",
- "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==",
+ "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.3.0",
- "@jest/test-result": "30.3.0",
- "@jest/types": "30.3.0",
+ "@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.3.0",
- "jest-util": "30.3.0",
- "jest-validate": "30.3.0",
+ "jest-config": "30.4.2",
+ "jest-util": "30.4.1",
+ "jest-validate": "30.4.1",
"yargs": "^17.7.2"
},
"bin": {
@@ -17310,33 +17267,33 @@
}
},
"node_modules/jest-config": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz",
- "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==",
+ "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": {
"@babel/core": "^7.27.4",
"@jest/get-type": "30.1.0",
- "@jest/pattern": "30.0.1",
- "@jest/test-sequencer": "30.3.0",
- "@jest/types": "30.3.0",
- "babel-jest": "30.3.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.3.0",
- "jest-docblock": "30.2.0",
- "jest-environment-node": "30.3.0",
- "jest-regex-util": "30.0.1",
- "jest-resolve": "30.3.0",
- "jest-runner": "30.3.0",
- "jest-util": "30.3.0",
- "jest-validate": "30.3.0",
+ "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.3.0",
+ "pretty-format": "30.4.1",
"slash": "^3.0.0",
"strip-json-comments": "^3.1.1"
},
@@ -17360,6 +17317,19 @@
}
}
},
+ "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": {
+ "@sinclair/typebox": "^0.34.0"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
"node_modules/jest-config/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -17394,38 +17364,45 @@
}
},
"node_modules/jest-config/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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.0.5",
+ "@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "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/jest-config/node_modules/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/jest-diff": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz",
- "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==",
+ "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": {
- "@jest/diff-sequences": "30.3.0",
+ "@jest/diff-sequences": "30.4.0",
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
- "pretty-format": "30.3.0"
+ "pretty-format": "30.4.1"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "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": {
+ "@sinclair/typebox": "^0.34.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -17465,31 +17442,25 @@
}
},
"node_modules/jest-diff/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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.0.5",
+ "@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "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/jest-diff/node_modules/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/jest-docblock": {
- "version": "30.2.0",
- "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz",
- "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==",
+ "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": {
@@ -17500,17 +17471,30 @@
}
},
"node_modules/jest-each": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz",
- "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==",
+ "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": {
"@jest/get-type": "30.1.0",
- "@jest/types": "30.3.0",
+ "@jest/types": "30.4.1",
"chalk": "^4.1.2",
- "jest-util": "30.3.0",
- "pretty-format": "30.3.0"
+ "jest-util": "30.4.1",
+ "pretty-format": "30.4.1"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "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": {
+ "@sinclair/typebox": "^0.34.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -17550,61 +17534,55 @@
}
},
"node_modules/jest-each/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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.0.5",
+ "@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "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/jest-each/node_modules/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/jest-environment-node": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz",
- "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==",
+ "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": {
- "@jest/environment": "30.3.0",
- "@jest/fake-timers": "30.3.0",
- "@jest/types": "30.3.0",
+ "@jest/environment": "30.4.1",
+ "@jest/fake-timers": "30.4.1",
+ "@jest/types": "30.4.1",
"@types/node": "*",
- "jest-mock": "30.3.0",
- "jest-util": "30.3.0",
- "jest-validate": "30.3.0"
+ "jest-mock": "30.4.1",
+ "jest-util": "30.4.1",
+ "jest-validate": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-haste-map": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz",
- "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==",
+ "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/types": "30.3.0",
+ "@jest/types": "30.4.1",
"@types/node": "*",
"anymatch": "^3.1.3",
"fb-watchman": "^2.0.2",
"graceful-fs": "^4.2.11",
- "jest-regex-util": "30.0.1",
- "jest-util": "30.3.0",
- "jest-worker": "30.3.0",
+ "jest-regex-util": "30.4.0",
+ "jest-util": "30.4.1",
+ "jest-worker": "30.4.1",
"picomatch": "^4.0.3",
"walker": "^1.0.8"
},
@@ -17616,52 +17594,72 @@
}
},
"node_modules/jest-leak-detector": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz",
- "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==",
+ "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": {
"@jest/get-type": "30.1.0",
- "pretty-format": "30.3.0"
+ "pretty-format": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "node_modules/jest-leak-detector/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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": {
- "@jest/schemas": "30.0.5",
- "ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "@sinclair/typebox": "^0.34.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "node_modules/jest-leak-detector/node_modules/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==",
+ "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"
+ "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/jest-matcher-utils": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz",
- "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==",
+ "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": {
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
- "jest-diff": "30.3.0",
- "pretty-format": "30.3.0"
+ "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/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": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -17701,41 +17699,36 @@
}
},
"node_modules/jest-matcher-utils/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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.0.5",
+ "@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "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/jest-matcher-utils/node_modules/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/jest-message-util": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz",
- "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==",
+ "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.3.0",
+ "@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.3.0",
+ "pretty-format": "30.4.1",
"slash": "^3.0.0",
"stack-utils": "^2.0.6"
},
@@ -17743,6 +17736,19 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
+ "node_modules/jest-message-util/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-message-util/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -17777,37 +17783,31 @@
}
},
"node_modules/jest-message-util/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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.0.5",
+ "@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "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/jest-message-util/node_modules/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/jest-mock": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz",
- "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==",
+ "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.3.0",
+ "@jest/types": "30.4.1",
"@types/node": "*",
- "jest-util": "30.3.0"
+ "jest-util": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -17832,9 +17832,9 @@
}
},
"node_modules/jest-regex-util": {
- "version": "30.0.1",
- "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz",
- "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==",
+ "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": {
@@ -17842,18 +17842,18 @@
}
},
"node_modules/jest-resolve": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz",
- "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==",
+ "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": {
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
- "jest-haste-map": "30.3.0",
+ "jest-haste-map": "30.4.1",
"jest-pnp-resolver": "^1.2.3",
- "jest-util": "30.3.0",
- "jest-validate": "30.3.0",
+ "jest-util": "30.4.1",
+ "jest-validate": "30.4.1",
"slash": "^3.0.0",
"unrs-resolver": "^1.7.11"
},
@@ -17862,14 +17862,14 @@
}
},
"node_modules/jest-resolve-dependencies": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz",
- "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==",
+ "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": {
- "jest-regex-util": "30.0.1",
- "jest-snapshot": "30.3.0"
+ "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"
@@ -17909,32 +17909,32 @@
}
},
"node_modules/jest-runner": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz",
- "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==",
+ "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.3.0",
- "@jest/environment": "30.3.0",
- "@jest/test-result": "30.3.0",
- "@jest/transform": "30.3.0",
- "@jest/types": "30.3.0",
+ "@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.2.0",
- "jest-environment-node": "30.3.0",
- "jest-haste-map": "30.3.0",
- "jest-leak-detector": "30.3.0",
- "jest-message-util": "30.3.0",
- "jest-resolve": "30.3.0",
- "jest-runtime": "30.3.0",
- "jest-util": "30.3.0",
- "jest-watcher": "30.3.0",
- "jest-worker": "30.3.0",
+ "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"
},
@@ -17976,32 +17976,32 @@
}
},
"node_modules/jest-runtime": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz",
- "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==",
+ "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": {
- "@jest/environment": "30.3.0",
- "@jest/fake-timers": "30.3.0",
- "@jest/globals": "30.3.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.3.0",
- "@jest/transform": "30.3.0",
- "@jest/types": "30.3.0",
+ "@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.3.0",
- "jest-message-util": "30.3.0",
- "jest-mock": "30.3.0",
- "jest-regex-util": "30.0.1",
- "jest-resolve": "30.3.0",
- "jest-snapshot": "30.3.0",
- "jest-util": "30.3.0",
+ "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"
},
@@ -18043,9 +18043,9 @@
}
},
"node_modules/jest-snapshot": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz",
- "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==",
+ "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": {
@@ -18054,20 +18054,20 @@
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.27.1",
"@babel/types": "^7.27.3",
- "@jest/expect-utils": "30.3.0",
+ "@jest/expect-utils": "30.4.1",
"@jest/get-type": "30.1.0",
- "@jest/snapshot-utils": "30.3.0",
- "@jest/transform": "30.3.0",
- "@jest/types": "30.3.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.3.0",
+ "expect": "30.4.1",
"graceful-fs": "^4.2.11",
- "jest-diff": "30.3.0",
- "jest-matcher-utils": "30.3.0",
- "jest-message-util": "30.3.0",
- "jest-util": "30.3.0",
- "pretty-format": "30.3.0",
+ "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"
},
@@ -18075,6 +18075,19 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
+ "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": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
"node_modules/jest-snapshot/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -18109,35 +18122,29 @@
}
},
"node_modules/jest-snapshot/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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.0.5",
+ "@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "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/jest-snapshot/node_modules/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/jest-util": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz",
- "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==",
+ "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.3.0",
+ "@jest/types": "30.4.1",
"@types/node": "*",
"chalk": "^4.1.2",
"ci-info": "^4.2.0",
@@ -18182,18 +18189,31 @@
}
},
"node_modules/jest-validate": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz",
- "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==",
+ "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.3.0",
+ "@jest/types": "30.4.1",
"camelcase": "^6.3.0",
"chalk": "^4.1.2",
"leven": "^3.1.0",
- "pretty-format": "30.3.0"
+ "pretty-format": "30.4.1"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "node_modules/jest-validate/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"
@@ -18246,41 +18266,35 @@
}
},
"node_modules/jest-validate/node_modules/pretty-format": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
- "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "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.0.5",
+ "@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
- "react-is": "^18.3.1"
+ "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/jest-validate/node_modules/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/jest-watcher": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz",
- "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==",
+ "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": {
- "@jest/test-result": "30.3.0",
- "@jest/types": "30.3.0",
+ "@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.3.0",
+ "jest-util": "30.4.1",
"string-length": "^4.0.2"
},
"engines": {
@@ -18321,15 +18335,15 @@
}
},
"node_modules/jest-worker": {
- "version": "30.3.0",
- "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz",
- "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==",
+ "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": {
"@types/node": "*",
"@ungap/structured-clone": "^1.3.0",
- "jest-util": "30.3.0",
+ "jest-util": "30.4.1",
"merge-stream": "^2.0.0",
"supports-color": "^8.1.1"
},
@@ -19032,7 +19046,7 @@
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
@@ -19165,16 +19179,6 @@
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
},
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/meriyah": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz",
@@ -19201,20 +19205,6 @@
"node": ">= 0.6"
}
},
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
- "dev": true,
- "license": "MIT",
- "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",
@@ -20479,9 +20469,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.13",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
- "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"devOptional": true,
"funding": [
{
@@ -20798,27 +20788,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "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==",
- "dev": 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"
- }
- ],
- "license": "MIT"
- },
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@@ -20976,6 +20945,22 @@
"dev": true,
"license": "MIT"
},
+ "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-is-19": {
+ "name": "react-is",
+ "version": "19.2.6",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
+ "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -21322,17 +21307,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/reusify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
- "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
"node_modules/reverbnation-scraper": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/reverbnation-scraper/-/reverbnation-scraper-2.0.0.tgz",
@@ -21392,14 +21366,14 @@
}
},
"node_modules/rolldown": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
- "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
+ "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.127.0",
- "@rolldown/pluginutils": "1.0.0-rc.17"
+ "@oxc-project/types": "=0.128.0",
+ "@rolldown/pluginutils": "1.0.0-rc.18"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -21408,27 +21382,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-rc.17",
- "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
- "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
- "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
+ "@rolldown/binding-android-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.18",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
- "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
+ "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
"dev": true,
"license": "MIT"
},
@@ -21492,30 +21466,6 @@
"node": ">= 18"
}
},
- "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==",
- "dev": 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"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -21561,19 +21511,19 @@
"license": "MIT"
},
"node_modules/secretlint": {
- "version": "12.3.1",
- "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-12.3.1.tgz",
- "integrity": "sha512-wv8TKCjU5hbBxo5jKEX8wIE78VAoL0Ux7pu18+TxtbICMZ2OCbu6EmO3OJLbUbyfUXSPVryNLNmGVgvwY6Z0xw==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-13.0.0.tgz",
+ "integrity": "sha512-bc4dzeaYMiuorNAb4cPoOX87gb+w0kiReZaUEx9iu8u0/PrkMj5QCm6pXji/NDc4Z6HUZO5ihfd0HY/4iPzskQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@secretlint/config-creator": "12.3.1",
- "@secretlint/formatter": "12.3.1",
- "@secretlint/node": "12.3.1",
- "@secretlint/profiler": "12.3.1",
- "@secretlint/resolver": "12.3.1",
+ "@secretlint/config-creator": "13.0.0",
+ "@secretlint/formatter": "13.0.0",
+ "@secretlint/node": "13.0.0",
+ "@secretlint/profiler": "13.0.0",
+ "@secretlint/resolver": "13.0.0",
+ "@secretlint/walker": "13.0.0",
"debug": "^4.4.3",
- "globby": "^16.2.0",
"read-pkg": "^10.1.0"
},
"bin": {
@@ -22714,9 +22664,9 @@
}
},
"node_modules/tailwindcss": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
- "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
+ "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
@@ -23015,19 +22965,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
- "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==",
- "dev": true,
- "license": "MIT",
- "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",
@@ -23702,16 +23639,16 @@
}
},
"node_modules/vite": {
- "version": "8.0.10",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
- "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
+ "version": "8.0.11",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
+ "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
- "postcss": "^8.5.10",
- "rolldown": "1.0.0-rc.17",
+ "postcss": "^8.5.14",
+ "rolldown": "1.0.0-rc.18",
"tinyglobby": "^0.2.16"
},
"bin": {
@@ -23728,7 +23665,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.1.0",
+ "@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -24456,7 +24393,7 @@
},
"packages/backend": {
"name": "@lucky/backend",
- "version": "2.9.0",
+ "version": "2.10.0",
"dependencies": {
"@lucky/shared": "file:../shared",
"connect-redis": "^9.0.0",
@@ -24473,10 +24410,10 @@
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/jest": "^30.0.0",
- "@types/node": "^25.6.0",
+ "@types/node": "^25.6.2",
"@types/session-file-store": "^1.2.6",
"@types/supertest": "^7.2.0",
- "jest": "^30.2.0",
+ "jest": "^30.4.2",
"supertest": "^7.0.0",
"ts-jest": "^29.4.9",
"tsup": "^8.5.1",
@@ -24485,9 +24422,9 @@
}
},
"packages/backend/node_modules/@types/node": {
- "version": "25.6.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
- "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "version": "25.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
+ "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -24517,7 +24454,7 @@
},
"packages/bot": {
"name": "@lucky/bot",
- "version": "2.9.0",
+ "version": "2.10.0",
"dependencies": {
"@discord-player/extractor": "^7.2.0",
"@discordjs/builders": "^1.14.1",
@@ -24539,9 +24476,9 @@
},
"devDependencies": {
"@types/jest": "^30.0.0",
- "@types/node": "^25.6.0",
+ "@types/node": "^25.6.2",
"@types/ws": "^8.5.13",
- "jest": "^30.2.0",
+ "jest": "^30.4.2",
"ts-jest": "^29.4.9",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
@@ -24549,9 +24486,9 @@
}
},
"packages/bot/node_modules/@types/node": {
- "version": "25.6.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
- "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "version": "25.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
+ "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -24624,7 +24561,7 @@
},
"packages/frontend": {
"name": "lucky-webapp",
- "version": "2.9.0",
+ "version": "2.10.0",
"dependencies": {
"@hookform/resolvers": "^5.0.0",
"@radix-ui/react-avatar": "^1.1.11",
@@ -24661,16 +24598,16 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
- "@tailwindcss/postcss": "^4.2.4",
+ "@tailwindcss/postcss": "^4.3.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
- "@types/node": "^25.6.0",
+ "@types/node": "^25.6.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
- "@types/three": "^0.184.0",
- "@typescript-eslint/eslint-plugin": "^8.59.1",
+ "@types/three": "^0.184.1",
+ "@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
@@ -24679,11 +24616,11 @@
"eslint-plugin-react-refresh": "^0.5.0",
"jsdom": "^29.1.1",
"playwright": "^1.57.0",
- "postcss": "^8.5.13",
- "tailwindcss": "^4.1.18",
+ "postcss": "^8.5.14",
+ "tailwindcss": "^4.3.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
- "vite": "^8.0.10",
+ "vite": "^8.0.11",
"vitest": "^4.0.18"
}
},
@@ -24854,9 +24791,9 @@
}
},
"packages/frontend/node_modules/@types/node": {
- "version": "25.6.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
- "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "version": "25.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
+ "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -25085,7 +25022,7 @@
},
"packages/shared": {
"name": "@lucky/shared",
- "version": "2.9.0",
+ "version": "2.10.0",
"dependencies": {
"@gar/promise-retry": "^1.0.3",
"@npmcli/agent": "^4.0.0",
@@ -25112,8 +25049,8 @@
},
"devDependencies": {
"@swc/cli": "^0.8.1",
- "@swc/core": "^1.15.32",
- "@types/node": "^25.6.0",
+ "@swc/core": "^1.15.33",
+ "@types/node": "^25.6.2",
"glob": "^13.0.6",
"typescript": "^6.0.3"
},
@@ -25182,9 +25119,9 @@
}
},
"packages/shared/node_modules/@types/node": {
- "version": "25.6.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
- "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "version": "25.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
+ "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index defbdc1fb..68ddd2386 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lucky-bot",
- "version": "2.9.0",
+ "version": "2.10.0",
"description": "All-in-one Discord bot platform \u2014 music, moderation, auto-mod, custom commands, and web dashboard",
"type": "module",
"workspaces": [
@@ -64,7 +64,7 @@
}
},
"engines": {
- "node": "22.x"
+ "node": ">=22 <27"
},
"keywords": [
"discord-bot",
@@ -76,19 +76,19 @@
"author": "",
"license": "ISC",
"devDependencies": {
- "@commitlint/cli": "^20.5.3",
- "@commitlint/config-conventional": "^20.5.3",
+ "@commitlint/cli": "^21.0.0",
+ "@commitlint/config-conventional": "^21.0.0",
"@eslint/js": "^10.0.1",
- "@secretlint/secretlint-rule-preset-recommend": "^12.3.1",
+ "@secretlint/secretlint-rule-preset-recommend": "^13.0.0",
"@types/jest": "^30.0.0",
- "@typescript-eslint/eslint-plugin": "^8.59.1",
+ "@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.54.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.6.0",
- "jest": "^30.2.0",
+ "jest": "^30.4.2",
"prettier": "^3.8.3",
"prisma": "^7.8.0",
- "secretlint": "^12.3.1",
+ "secretlint": "^13.0.0",
"ts-jest": "^29.4.9",
"unfetch": "^5.0.0"
},
diff --git a/packages/backend/package.json b/packages/backend/package.json
index dc76103b6..470e85618 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@lucky/backend",
- "version": "2.9.0",
+ "version": "2.10.0",
"description": "Express API server for Lucky",
"type": "module",
"main": "./dist/index.js",
@@ -32,10 +32,10 @@
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/jest": "^30.0.0",
- "@types/node": "^25.6.0",
+ "@types/node": "^25.6.2",
"@types/session-file-store": "^1.2.6",
"@types/supertest": "^7.2.0",
- "jest": "^30.2.0",
+ "jest": "^30.4.2",
"supertest": "^7.0.0",
"ts-jest": "^29.4.9",
"tsx": "^4.21.0",
diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts
index 1ea07a4a8..5636e0709 100644
--- a/packages/backend/src/routes/index.ts
+++ b/packages/backend/src/routes/index.ts
@@ -27,6 +27,7 @@ import { requireGuildModuleAccess } from '../middleware/guildAccess'
import { errorHandler } from '../middleware/errorHandler'
import { setupHealthRoutes } from './health'
import { setupStatsRoutes } from './stats'
+import { setupInviteRoute } from './invite'
type GuildGuardConfig = {
path: string
@@ -81,6 +82,7 @@ const routeSetups = [
]
export function setupRoutes(app: Express): void {
+ setupInviteRoute(app)
setupHealthRoutes(app)
setupStatsRoutes(app)
setupInternalNotifyRoutes(app)
diff --git a/packages/backend/src/routes/invite.ts b/packages/backend/src/routes/invite.ts
new file mode 100644
index 000000000..c43ac4a98
--- /dev/null
+++ b/packages/backend/src/routes/invite.ts
@@ -0,0 +1,31 @@
+import type { Express, Request, Response } from 'express'
+import { infoLog } from '@lucky/shared/utils'
+import { logAndSwallow } from '@lucky/shared/utils/error'
+import { apiLimiter } from '../middleware/rateLimit'
+
+const DISCORD_INVITE_URL =
+ 'https://discord.com/oauth2/authorize?client_id=962198089161134131&scope=bot%20applications.commands&permissions=36970496'
+
+function toUtmString(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined
+}
+
+export function setupInviteRoute(app: Express): void {
+ app.get('/invite', apiLimiter, (req: Request, res: Response) => {
+ const utm_source = toUtmString(req.query.utm_source)
+ const utm_medium = toUtmString(req.query.utm_medium)
+ const utm_campaign = toUtmString(req.query.utm_campaign)
+ const utm_content = toUtmString(req.query.utm_content)
+
+ try {
+ infoLog({
+ message: '[invite] click',
+ data: { utm_source, utm_medium, utm_campaign, utm_content },
+ })
+ } catch (err) {
+ logAndSwallow(err, 'invite.infoLog')
+ }
+
+ res.redirect(302, DISCORD_INVITE_URL)
+ })
+}
diff --git a/packages/backend/tests/unit/middleware/asyncHandler.test.ts b/packages/backend/tests/unit/middleware/asyncHandler.test.ts
deleted file mode 100644
index b1f30a733..000000000
--- a/packages/backend/tests/unit/middleware/asyncHandler.test.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { describe, test, expect, jest } from '@jest/globals'
-import { asyncHandler } from '../../../src/middleware/asyncHandler'
-
-describe('asyncHandler', () => {
- test('should call the handler function', async () => {
- const handler = jest.fn()
- const wrapped = asyncHandler(handler as any)
- const req = {} as any
- const res = {} as any
- const next = jest.fn()
-
- await wrapped(req, res, next)
-
- expect(handler).toHaveBeenCalledWith(req, res, next)
- })
-
- test('should forward rejected promise to next', async () => {
- const error = new Error('async failure')
- const handler = jest.fn().mockRejectedValue(error)
- const wrapped = asyncHandler(handler as any)
- const next = jest.fn()
-
- await wrapped({} as any, {} as any, next)
-
- await new Promise((r) => setTimeout(r, 0))
- expect(next).toHaveBeenCalledWith(error)
- })
-
- test('should forward synchronous throw to next', () => {
- const error = new Error('sync throw')
- const handler = jest.fn().mockImplementation(() => {
- throw error
- })
- const wrapped = asyncHandler(handler as any)
- const next = jest.fn()
-
- wrapped({} as any, {} as any, next)
-
- expect(next).toHaveBeenCalledWith(error)
- })
-
- test('should not call next on success', async () => {
- const handler = jest.fn().mockResolvedValue(undefined)
- const wrapped = asyncHandler(handler as any)
- const next = jest.fn()
-
- await wrapped({} as any, {} as any, next)
-
- await new Promise((r) => setTimeout(r, 0))
- expect(next).not.toHaveBeenCalled()
- })
-})
diff --git a/packages/backend/tests/unit/routes/index.test.ts b/packages/backend/tests/unit/routes/index.test.ts
index 1dca4a775..424a3d606 100644
--- a/packages/backend/tests/unit/routes/index.test.ts
+++ b/packages/backend/tests/unit/routes/index.test.ts
@@ -3,6 +3,7 @@ import type { Express } from 'express'
const setupHealthRoutes = jest.fn()
const setupStatsRoutes = jest.fn()
+const setupInviteRoute = jest.fn()
const setupAuthRoutes = jest.fn()
const setupToggleRoutes = jest.fn()
const setupGuildRoutes = jest.fn()
@@ -41,6 +42,10 @@ jest.mock('../../../src/routes/stats', () => ({
setupStatsRoutes,
}))
+jest.mock('../../../src/routes/invite', () => ({
+ setupInviteRoute,
+}))
+
jest.mock('../../../src/routes/auth', () => ({
setupAuthRoutes,
}))
@@ -149,11 +154,12 @@ jest.mock('../../../src/middleware/errorHandler', () => ({
import { setupRoutes } from '../../../src/routes'
-type MockApp = Pick
+type MockApp = Pick
describe('setupRoutes', () => {
const app: MockApp = {
use: jest.fn() as unknown as Express['use'],
+ get: jest.fn() as unknown as Express['get'],
}
beforeEach(() => {
@@ -169,6 +175,7 @@ describe('setupRoutes', () => {
expect(setupHealthRoutes).toHaveBeenCalledWith(app)
expect(setupStatsRoutes).toHaveBeenCalledWith(app)
+ expect(setupInviteRoute).toHaveBeenCalledWith(app)
expect(setupInternalNotifyRoutes).toHaveBeenCalledWith(app)
expect(setupWebhookPublicRoutes).toHaveBeenCalledWith(app)
expect(app.use).toHaveBeenCalledWith('/api/', apiLimiter)
diff --git a/packages/backend/tests/unit/routes/invite.test.ts b/packages/backend/tests/unit/routes/invite.test.ts
new file mode 100644
index 000000000..3fca0a548
--- /dev/null
+++ b/packages/backend/tests/unit/routes/invite.test.ts
@@ -0,0 +1,92 @@
+import { describe, test, expect, jest, beforeEach } from '@jest/globals'
+import express from 'express'
+import request from 'supertest'
+
+const mockInfoLog = jest.fn()
+const mockLogAndSwallow = jest.fn()
+
+jest.mock('@lucky/shared/utils', () => ({
+ infoLog: (...args: unknown[]) => mockInfoLog(...args),
+}))
+
+jest.mock('@lucky/shared/utils/error', () => ({
+ logAndSwallow: (...args: unknown[]) => mockLogAndSwallow(...args),
+}))
+
+jest.mock('../../../src/middleware/rateLimit', () => ({
+ apiLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
+}))
+
+import { setupInviteRoute } from '../../../src/routes/invite'
+
+function buildApp() {
+ const app = express()
+ setupInviteRoute(app)
+ return app
+}
+
+describe('GET /invite', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockInfoLog.mockReset()
+ })
+
+ test('redirects to Discord invite URL', async () => {
+ const res = await request(buildApp()).get('/invite')
+ expect(res.status).toBe(302)
+ expect(res.headers.location).toContain('discord.com/oauth2/authorize')
+ })
+
+ test('logs UTM params when present', async () => {
+ await request(buildApp()).get(
+ '/invite?utm_source=github&utm_medium=readme&utm_campaign=test&utm_content=badge',
+ )
+ expect(mockInfoLog).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ utm_source: 'github',
+ utm_medium: 'readme',
+ utm_campaign: 'test',
+ utm_content: 'badge',
+ }),
+ }),
+ )
+ })
+
+ test('logs undefined for missing UTM params', async () => {
+ await request(buildApp()).get('/invite')
+ expect(mockInfoLog).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: {
+ utm_source: undefined,
+ utm_medium: undefined,
+ utm_campaign: undefined,
+ utm_content: undefined,
+ },
+ }),
+ )
+ })
+
+ test('coerces array UTM values to undefined', async () => {
+ await request(buildApp()).get('/invite?utm_source=a&utm_source=b')
+ const logged = (mockInfoLog.mock.calls[0] as [{ data: { utm_source: unknown } }])[0]
+ expect(logged.data.utm_source).toBeUndefined()
+ })
+
+ test('still redirects when infoLog throws', async () => {
+ mockInfoLog.mockImplementation(() => {
+ throw new Error('logging failure')
+ })
+ const res = await request(buildApp()).get('/invite')
+ expect(res.status).toBe(302)
+ expect(res.headers.location).toContain('discord.com')
+ })
+
+ test('never redirects to a user-supplied URL (no open redirect)', async () => {
+ const res = await request(buildApp()).get(
+ '/invite?redirect=https://evil.example.com',
+ )
+ expect(res.headers.location).not.toContain('evil.example.com')
+ expect(res.headers.location).toContain('discord.com')
+ })
+})
diff --git a/packages/backend/tests/unit/routes/musicSSEBridge.test.ts b/packages/backend/tests/unit/routes/musicSSEBridge.test.ts
deleted file mode 100644
index cbf7909f9..000000000
--- a/packages/backend/tests/unit/routes/musicSSEBridge.test.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { describe, test, expect, jest, beforeEach } from '@jest/globals'
-import type { Response } from 'express'
-
-const connectMock = jest.fn()
-const subscribeToResultsMock = jest.fn()
-const subscribeToStateMock = jest.fn()
-const infoLogMock = jest.fn()
-const errorLogMock = jest.fn()
-
-// sseClients is a module-level Map — we control it via the helper module mock
-const mockSseClients = new Map>()
-
-jest.mock('@lucky/shared/services', () => ({
- musicControlService: {
- connect: (...args: unknown[]) => connectMock(...args),
- subscribeToResults: (...args: unknown[]) => subscribeToResultsMock(...args),
- subscribeToState: (...args: unknown[]) => subscribeToStateMock(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- infoLog: (...args: unknown[]) => infoLogMock(...args),
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-jest.mock('../../../src/routes/music/helpers', () => ({
- sseClients: mockSseClients,
-}))
-
-// Route setup stubs (we only care about the SSE bridge behavior)
-jest.mock('../../../src/routes/music/playbackRoutes', () => ({ setupPlaybackRoutes: jest.fn() }))
-jest.mock('../../../src/routes/music/queueRoutes', () => ({ setupQueueRoutes: jest.fn() }))
-jest.mock('../../../src/routes/music/stateRoutes', () => ({ setupStateRoutes: jest.fn() }))
-jest.mock('../../../src/routes/music/autoplayRoutes', () => ({ setupAutoplayRoutes: jest.fn() }))
-
-describe('music SSE bridge (initMusicSSEBridge)', () => {
- beforeEach(() => {
- mockSseClients.clear()
- connectMock.mockResolvedValue(undefined)
- subscribeToResultsMock.mockResolvedValue(undefined)
- subscribeToStateMock.mockResolvedValue(undefined)
- })
-
- async function initBridge() {
- jest.resetModules()
- connectMock.mockResolvedValue(undefined)
- subscribeToResultsMock.mockResolvedValue(undefined)
- subscribeToStateMock.mockResolvedValue(undefined)
-
- const { setupMusicRoutes } = await import('../../../src/routes/music/index')
- setupMusicRoutes({} as never)
- // Let the async initMusicSSEBridge settle
- await new Promise((r) => setTimeout(r, 10))
- }
-
- test('calls connect, subscribeToResults, and subscribeToState in order', async () => {
- await initBridge()
- expect(connectMock).toHaveBeenCalledTimes(1)
- expect(subscribeToResultsMock).toHaveBeenCalledTimes(1)
- expect(subscribeToStateMock).toHaveBeenCalledTimes(1)
- expect(infoLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Music SSE bridge initialized successfully' }),
- )
- })
-
- test('logs error and stops when connect throws', async () => {
- connectMock.mockRejectedValue(new Error('Redis unavailable'))
- jest.resetModules()
- const { setupMusicRoutes } = await import('../../../src/routes/music/index')
- setupMusicRoutes({} as never)
- await new Promise((r) => setTimeout(r, 10))
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Failed to initialize music SSE bridge:' }),
- )
- expect(subscribeToResultsMock).not.toHaveBeenCalled()
- })
-
- describe('subscribeToState callback', () => {
- async function getStateCallback() {
- await initBridge()
- return subscribeToStateMock.mock.calls[0][0] as (state: unknown) => void
- }
-
- test('does nothing when no clients registered for guild', async () => {
- const cb = await getStateCallback()
- cb({ guildId: 'guild-1', isPlaying: true, tracks: [] })
- expect(infoLogMock).not.toHaveBeenCalledWith(
- expect.objectContaining({ message: expect.stringContaining('broadcast') }),
- )
- })
-
- test('writes state to registered SSE clients', async () => {
- const writeMock = jest.fn()
- const mockClient = { write: (...args: unknown[]) => writeMock(...args) } as unknown as Response
- mockSseClients.set('guild-2', new Set([mockClient]))
-
- const cb = await getStateCallback()
- const state = { guildId: 'guild-2', isPlaying: true, tracks: [] }
- cb(state)
-
- expect(writeMock).toHaveBeenCalledWith(
- expect.stringContaining(`"guildId":"guild-2"`),
- )
- expect(infoLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Music state broadcast to 1 clients'),
- }),
- )
- })
-
- test('logs error when write throws and continues', async () => {
- const failingClient = {
- write: jest.fn(() => { throw new Error('socket closed') }),
- } as unknown as Response
- const goodWrite = jest.fn()
- const goodClient = { write: (...args: unknown[]) => goodWrite(...args) } as unknown as Response
- mockSseClients.set('guild-3', new Set([failingClient, goodClient]))
-
- const cb = await getStateCallback()
- cb({ guildId: 'guild-3', isPlaying: false, tracks: [] })
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Failed to write to SSE client' }),
- )
- expect(goodWrite).toHaveBeenCalled()
- })
- })
-})
diff --git a/packages/bot/TEST_MAP.md b/packages/bot/TEST_MAP.md
new file mode 100644
index 000000000..10c022e56
--- /dev/null
+++ b/packages/bot/TEST_MAP.md
@@ -0,0 +1,1858 @@
+# Lucky Bot — Test Map
+
+> Generated: 2026-05-08
+> Total: 2624 tests across 200 suites
+
+## How to read this document
+
+**Importance:**
+- **Critical** — tests that catch regressions in core features (music playback, queue management, autoplay)
+- **High** — important feature behavior (moderation, Discord interactions, error handling)
+- **Medium** — utility/helper functions, secondary features
+- **Low** — edge cases, implementation details, logging, trivial wrappers
+
+**Coverage impact:**
+- **High** — tests exercise meaningful code paths; removing them leaves real behavior untested
+- **Medium** — tests exercise some real paths but miss important branches
+- **Low** — tests verify only mock calls (coordinator tests) or trivial paths
+
+**Approach:**
+- **✅ Optimal** — pure behavioral tests, testing through public API, real state assertions
+- **⚠️ Acceptable** — unit tests with reasonable mocking, mostly behavioral
+- **❌ Suboptimal** — coordinator tests (only verify mocks), log-only assertions, or tests better as integration tests
+
+---
+
+## Domain: Core Music Playback
+**Total:** 63 tests across 4 files
+
+### `handlers/player/streamBridge.spec.ts` — 22 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- streamViaYtDlp – URL validation
+- streamViaYtDlp – process lifecycle
+- streamViaYtDlpSearch
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/player/trackHandlers.spec.ts` — 19 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- trackHandlers autoplay replenishment
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/player/errorHandlers.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- setupErrorHandlers
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/player/lifecycleHandlers.spec.ts` — 9 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- setupLifecycleHandlers
+- setupVoiceKickDetection
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+
+## Domain: Queue Management
+**Total:** 254 tests across 13 files
+
+### `utils/music/queueManipulation.spec.ts` — 114 tests
+
+**Importance:** Critical | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- queueManipulation.replenishQueue
+- queueManipulation.queueOperations
+- queueManipulation — title-only deduplication
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/queueStateManager.spec.ts` — 60 tests
+
+**Importance:** High | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- queueStateManager
+- getQueueState
+- isQueueEmpty
+
+**Notes:** Comprehensive — covers major code paths.
+
+### `utils/music/queueEditOps.spec.ts` — 26 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- clearQueue
+- shuffleQueue
+- smartShuffleQueue
+
+
+### `utils/music/queueRescue.spec.ts` — 17 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- rescueQueue
+- getHistoryTracks
+- buildVcContributionWeights
+
+
+### `functions/music/commands/autoplay/queueHandlers.spec.ts` — 12 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- isAutoplayTrack
+- handleAutoplayStatus
+- handleSkipAutoplayTrack
+
+
+### `handlers/queueHandler.spec.ts` — 12 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- queueHandler
+- createQueue
+- queueConnect
+
+
+### `utils/music/queueResolver.spec.ts` — 9 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- queueResolver
+
+
+### `handlers/webMusic/queueHandlers.spec.ts` — 2 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- web music queueHandlers queue resolution
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `functions/music/commands/queueResolverWiring.spec.ts` — 1 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- music command resolver wiring
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/queueResolver.guard.spec.ts` — 1 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- queue resolver guardrails
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/music/queue/asyncQueueManager.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- AsyncQueueManager
+- addTracksSafely
+- playTrackSafely
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `utils/music/queue/queueStrategy.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- QueueStrategyManager
+- addTrackWithStrategy
+- addTracksWithStrategy
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `utils/music/queue/smartShuffle.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- smartShuffle
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+
+## Domain: Now Playing & Track Display
+**Total:** 89 tests across 4 files
+
+### `handlers/player/trackNowPlaying.spec.ts` — 38 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- trackNowPlaying handlers
+- TrackNowPlayingState - registerNowPlayingMessage
+- TrackNowPlayingState - getSongInfoMessage
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/general/responseEmbeds/buildTrackEmbed.spec.ts` — 25 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- detectSource
+- buildTrackEmbed
+- trackToData
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/general/responseEmbeds/buildPlatformAttribEmbed.spec.ts` — 14 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- buildPlatformAttribEmbed
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/music/nowPlayingEmbed.spec.ts` — 12 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- detectSource
+- buildPlayResponseEmbed
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+
+## Domain: Autoplay System
+**Total:** 285 tests across 15 files
+
+### `utils/music/autoplay/candidateScorer.spec.ts` — 43 tests
+
+**Importance:** Critical | **Coverage impact:** Medium | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- candidateScorer
+- calculateRecommendationScore
+- calculateGenreFamilyPenalty
+
+**Notes:** Comprehensive — covers major code paths.
+
+### `functions/music/commands/autoplay.spec.ts` — 40 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- autoplay command
+- structure
+- skip subcommand
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/autoplay/lastFmSeeds.spec.ts` — 36 tests
+
+**Importance:** High | **Coverage impact:** Medium | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- getLastFmSeedTracks
+- getLastFmSeedSlice
+- advanceLastFmSeedOffset
+
+
+### `utils/music/autoplay/sessionMood.spec.ts` — 36 tests
+
+**Importance:** High | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- sessionMood
+- detectSessionMood - artist deep-dive
+- detectSessionMood - duration preferences
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `functions/music/commands/autoplay/settingsHandlers.spec.ts` — 31 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- handleAutoplayMode
+- handleAutoplayGenre
+- add subcommand
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/candidateFallback.spec.ts` — 24 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- interleaveByArtist
+- enrichWithAudioFeatures
+- collectGenreCandidates
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/autoplay/lastFmSeeder.spec.ts` — 20 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- searchLastFmQuery
+- collectLastFmCandidates
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/autoplay/candidateCollector.spec.ts` — 17 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- candidateCollector
+- shouldIncludeCandidate
+- upsertScoredCandidate
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/autoplay/artistHandlers.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- handleAutoplayArtist
+- missing guildId
+- subcommand: prefer
+
+
+### `utils/music/autoplay/spotifyRecommender.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- spotifyRecommender
+- collectSpotifyRecommendationCandidates
+- searchSeedCandidates
+
+
+### `utils/music/replenishSuppressionStore.spec.ts` — 9 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- replenishSuppressionStore
+- setReplenishSuppressed
+- isReplenishSuppressed
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `functions/music/commands/queue/queueStats.spec.ts` — 3 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- queueStats
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `utils/music/autoplay/counters.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- autoplay/counters
+- getAutoplayCount
+- incrementAutoplayCount
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `utils/music/autoplay/diversitySelector.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- diversitySelector
+- buildExcludedUrls
+- buildExcludedKeys
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/autoplay/stats.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- autoplay/stats
+- getAutoplayStats
+- shouldEnableAutoplay
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+
+## Domain: Search & Discovery
+**Total:** 183 tests across 8 files
+
+### `utils/music/searchQueryCleaner.spec.ts` — 96 tests
+
+**Importance:** High | **Coverage impact:** High | **Approach:** ✅ Optimal
+
+**Test groups:**
+- cleanTitle
+- cleanAuthor
+- cleanSearchQuery
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/music/search/providerHealth.spec.ts` — 19 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- ProviderHealthService
+- ProviderHealthService cooldown boundary conditions
+- provider mappers
+
+
+### `functions/music/commands/play/queryDetector.spec.ts` — 15 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- detectQueryType
+- youtube detection
+- spotify detection
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `functions/music/handlers/play/handlePlay.spec.ts` — 14 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- handlePlay
+- validateQuery
+- searchAndAddTrack
+
+
+### `functions/music/commands/play/queryUtils.spec.ts` — 12 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- normalizeSoundCloudUrl
+- isUrl
+- executePlayAtTop — fallback chain
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/play/youtubeHandler.spec.ts` — 12 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- youtubeHandler
+- handleSpotifySearch
+- handleYouTubeSearch
+
+
+### `utils/music/search/engineManager.spec.ts` — 8 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- SearchEngineManager
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/play/spotifyHandler.spec.ts` — 7 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- spotifyHandler
+- handleSpotifyTrack
+- handleSpotifyPlaylist
+
+
+
+## Domain: Track Metadata & Normalization
+**Total:** 222 tests across 10 files
+
+### `utils/music/youtubeErrorHandler/analyzer.spec.ts` — 56 tests
+
+**Importance:** High | **Coverage impact:** High | **Approach:** ✅ Optimal
+
+**Test groups:**
+- YouTubeErrorAnalyzer
+- analyzeError
+- parser error detection
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/music/titleComparison/service.spec.ts` — 52 tests
+
+**Importance:** High | **Coverage impact:** Medium | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- TitleComparisonService
+- constructor
+- extractArtistTitle
+
+**Notes:** Comprehensive — covers major code paths.
+
+### `utils/music/trackNormalization.spec.ts` — 31 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- normalizeText
+- FUZZY_TITLE_THRESHOLD
+- normalizeTrackKey
+
+
+### `utils/music/duplicateDetection/similarityChecker.spec.ts` — 22 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- calculateStringSimilarity
+- areTracksSimilar
+- findSimilarTracks
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/music/duplicateDetection/tagExtractor.spec.ts` — 22 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- tagExtractor
+- extractTags
+- extractGenre
+
+
+### `utils/music/languageHeuristics.spec.ts` — 21 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- languageHeuristics
+- detectSpanishMarkers
+- detectPortugueseMarkers
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/music/duplicateDetection/duplicateChecker.spec.ts` — 16 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- duplicateChecker
+- addTrackToHistory edge cases
+- checkForDuplicate error handling
+
+
+### `utils/music/trackUtils/trackProcessor.spec.ts` — 2 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- TrackProcessor.getTrackInfo
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `utils/music/trackUtils/cacheManager.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- LRUCache
+- basic operations
+- LRU eviction
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/music/trackUtils/index.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- TrackUtils class
+- getTrackInfo
+- getTrackCacheKey
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+
+## Domain: Spotify Integration
+**Total:** 72 tests across 3 files
+
+### `spotify/spotifyApi.spec.ts` — 57 tests
+
+**Importance:** Critical | **Coverage impact:** High | **Approach:** ✅ Optimal
+
+**Test groups:**
+- spotifyApi
+- getAudioFeatures
+- searchSpotifyTrack
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `spotify/spotifyUserSeeds.spec.ts` — 8 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- spotifyUserSeeds
+
+
+### `spotify/spotifyConfig.spec.ts` — 7 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- spotifyConfig
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+
+## Domain: Last.fm Integration
+**Total:** 83 tests across 2 files
+
+### `lastfm/lastFmApi.spec.ts` — 76 tests
+
+**Importance:** Critical | **Coverage impact:** High | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- lastFmApi
+- normalizeLastFmArtist
+- normalizeLastFmTitle
+
+**Notes:** Comprehensive — covers major code paths.
+
+### `handlers/externalScrobbler.spec.ts` — 7 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- externalScrobbler
+
+
+
+## Domain: Music Recommendation Service
+**Total:** 89 tests across 5 files
+
+### `services/musicRecommendation/feedbackService.spec.ts` — 38 tests
+
+**Importance:** High | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- RecommendationFeedbackService
+- implicit feedback
+- Postgres DB integration
+
+
+### `services/musicRecommendation/vectorOperations.spec.ts` — 32 tests
+
+**Importance:** High | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- vectorOperations
+- createTrackVector
+- calculateCosineSimilarity
+
+
+### `services/musicRecommendation/similarityCalculator.spec.ts` — 19 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- similarityCalculator
+- calculateTrackSimilarity
+- calculateDiversityScore
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `services/musicRecommendation/recommendationEngine.spec.ts` — 0 tests
+
+**Importance:** Low | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- recommendationEngine
+- generateRecommendations
+- generateUserPreferenceRecommendations
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `services/musicRecommendation/recommendationHelpers.spec.ts` — 0 tests
+
+**Importance:** Low | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- recommendationHelpers
+- createUserPreferenceSeed
+- applyDiversityFilter
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+
+## Domain: Music Commands
+**Total:** 311 tests across 37 files
+
+### `functions/music/commands/play/index.spec.ts` — 23 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- play command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/queue/queueDisplay.spec.ts` — 16 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- queueDisplay
+- formatTrackForDisplay
+- createTrackListDisplay
+
+
+### `functions/music/commands/queue/queueEmbed.spec.ts` — 15 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- queueEmbed
+- createQueueEmbed
+- createEmptyQueueEmbed
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/repeat.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- repeat command
+
+
+### `functions/music/commands/play/processor.spec.ts` — 12 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- PlayCommandProcessor
+- processPlayCommand — routing
+- processPlayCommand — queue management
+
+
+### `functions/music/commands/session.spec.ts` — 12 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- session command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/effects.spec.ts` — 11 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- effects command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/move.spec.ts` — 11 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- move command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/remove.spec.ts` — 11 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- remove command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/album.spec.ts` — 10 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- album command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/music.spec.ts` — 10 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- music command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/skip.spec.ts` — 10 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- skip command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/playskip.spec.ts` — 9 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- playskip command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/playtop.spec.ts` — 9 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- playtop command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/music/commands/shuffle.spec.ts` — 9 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- shuffle command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+
+## Domain: Session & Snapshot Management
+**Total:** 46 tests across 3 files
+
+### `utils/music/watchdog.spec.ts` — 25 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- MusicWatchdogService
+- MusicWatchdogService — orphan session monitor
+- MusicWatchdogService — constructor env var parsing
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/namedSessions.spec.ts` — 19 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- NamedSessionService
+- save
+- restore
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/watchdog.rejoin.spec.ts` — 2 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- MusicWatchdogService retry reconnect
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+
+## Domain: Handlers
+**Total:** 190 tests across 9 files
+
+### `handlers/messageHandler.spec.ts` — 35 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- handleMessageCreate — XP handling
+- handleMessageCreate — AutoMod handling
+- handleMessageCreate — Custom Commands handling
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/auditHandler.spec.ts` — 34 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- auditHandler
+- handleAuditEvents
+- MessageDelete event
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/eventHandler.spec.ts` — 22 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- eventHandler
+- handleAutocomplete
+- button interactions
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/interactionHandler.spec.ts` — 22 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- interactionHandler
+- handleInteractions
+- handleInteraction
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/commandsHandler.spec.ts` — 21 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- commandsHandler
+- executeCommand
+- setCommands
+
+
+### `handlers/memberHandler.spec.ts` — 21 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- memberHandler
+- handleMemberEvents
+- GuildMemberAdd event
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/musicButtonHandler.spec.ts` — 18 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- handleMusicButtonInteraction
+- leaderboard page button
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/reactionHandler.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- handleReactionEvents
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/playerHandler.spec.ts` — 4 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- playerHandler
+- module structure
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+
+## Domain: Web Music
+**Total:** 34 tests across 3 files
+
+### `handlers/webMusic/mappers.spec.ts` — 19 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- mapTrack
+- repeatModeToString
+- repeatModeToEnum
+
+
+### `handlers/webMusic/index.spec.ts` — 8 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- setupWebMusicHandler
+- periodic state publish (setInterval)
+
+
+### `handlers/webMusic/commandHandlers.spec.ts` — 7 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- handleStop
+- handleSkip
+- webMusic commandHandlers queue resolution
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+
+## Domain: Moderation
+**Total:** 16 tests across 15 files
+
+### `functions/moderation/commands/digest.spec.ts` — 15 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- digest command
+- view subcommand
+- schedule subcommand
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/moderation/commands/index.spec.ts` — 1 tests
+
+**Importance:** High | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- moderation command loader
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `functions/moderation/commands/ban.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- ban command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/moderation/commands/case.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- case command
+- view subcommand
+- update subcommand
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `functions/moderation/commands/cases.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- cases command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/moderation/commands/history.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- history command
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `functions/moderation/commands/kick.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- kick command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/moderation/commands/lockdown.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- lockdown command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/moderation/commands/mute.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- mute command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/moderation/commands/purge.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- purge command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/moderation/commands/slowmode.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- slowmode command
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `functions/moderation/commands/unban.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- unban command
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `functions/moderation/commands/unmute.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- unmute command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/moderation/commands/warn.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- warn command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/moderation/handlers/caseHandlers.spec.ts` — 0 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- caseHandlers
+- handleCaseView
+- handleCaseUpdate
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+
+## Domain: Management / Guild Config
+**Total:** 90 tests across 13 files
+
+### `functions/management/handlers/automessageHandlers.spec.ts` — 20 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- handleAutoMessageConfig
+- handleAutoMessageList
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/management/commands/helpers/serversetupCriativaria.spec.ts` — 17 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- serversetupCriativaria helpers
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/management/commands/guildconfig.spec.ts` — 11 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- guildconfig command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/management/commands/automessage.spec.ts` — 8 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- automessage command
+
+
+### `utils/guildAutomation/applyPlan.spec.ts` — 8 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- applyAutomationModules
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/management/commands/serversetup.spec.ts` — 6 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- serversetup command
+
+
+### `functions/management/commands/autorole.spec.ts` — 5 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- autorole command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/management/commands/settings.spec.ts` — 4 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- settings command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/guildAutomation/captureGuildState.spec.ts` — 3 tests
+
+**Importance:** Low | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- captureGuildAutomationState
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `utils/guildAutomation/diff.spec.ts` — 3 tests
+
+**Importance:** Low | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- createAutomationPlan
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/guildAutomation/manifestSchema.spec.ts` — 2 tests
+
+**Importance:** Low | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- guildAutomationManifestSchema
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/guildAutomation/onboardingMapper.spec.ts` — 2 tests
+
+**Importance:** Low | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- onboarding mapper
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `functions/management/commands/index.spec.ts` — 1 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- management command loader
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+
+## Domain: General Commands
+**Total:** 69 tests across 13 files
+
+### `functions/general/commands/giveaway.spec.ts` — 16 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- giveaway command
+- parseDuration
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/general/commands/level.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- level command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/general/commands/twitch.spec.ts` — 12 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- twitch command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/general/commands/lastfm.spec.ts` — 9 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- lastfm command link generation
+
+
+### `functions/general/commands/starboard.spec.ts` — 9 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- starboard command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/general/commands/voterewards.spec.ts` — 6 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- voterewards command
+
+
+### `functions/general/commands/version.spec.ts` — 4 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- version command
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `functions/general/commands/birthday.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- daysUntilBirthday
+- /birthday
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `functions/general/commands/help.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- help command
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `functions/general/commands/leaderboard.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- /leaderboard
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+### `functions/general/commands/reactionrole.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- reactionrole command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/general/commands/roleconfig.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- roleconfig command
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `functions/general/commands/social.spec.ts` — 0 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- /social
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+
+## Domain: Twitch Integration
+**Total:** 73 tests across 6 files
+
+### `twitch/token.spec.ts` — 20 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- twitch/token
+- getTwitchEnv
+- isTwitchConfigured
+
+
+### `functions/general/handlers/twitchHandlers.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- twitchHandlers
+- handleTwitchAdd
+- handleTwitchRemove
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `twitch/eventsubSubscriptions.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- eventsubSubscriptions
+- subscribeToStreamOnline
+- handleStreamOnline
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `twitch/index.spec.ts` — 11 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- twitch/index
+- startTwitchService
+- stopTwitchService
+
+
+### `twitch/twitchApi.spec.ts` — 11 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- twitchApi
+- getTwitchUserByLogin
+
+
+### `twitch/eventsubClient.spec.ts` — 5 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- TwitchEventSubClient
+- start
+- stop
+
+**Notes:** Minimal — smoke test or trivial wrapper.
+
+
+## Domain: Client & Bot Infrastructure
+**Total:** 99 tests across 5 files
+
+### `handlers/clientHandler/presence.spec.ts` — 32 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- presence
+- PRESENCE_ROTATION_INTERVAL_MS
+- nextPresenceIndex
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `bot/start/initializer.spec.ts` — 30 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- BotInitializer
+- initializeBot
+- shutdown
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/clientHandler/service.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- service
+- createClient
+- startClient
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/monitoring/health.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- Health Monitoring
+- checkDiscordHealth
+- checkRedisHealth
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/monitoring/metrics.spec.ts` — 11 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- Metrics Monitoring
+- recordCommandMetric
+- recordInteractionMetric
+
+
+
+## Domain: Utilities
+**Total:** 356 tests across 32 files
+
+### `utils/music/service.spec.ts` — 44 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- TrackManagementService
+- constructor
+- addTrackToQueue
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/player/playerFactory.bridge.spec.ts` — 40 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- parseDurationString
+- findMatchingSoundCloudResult
+- streamViaSoundCloud
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/general/interactionReply.spec.ts` — 30 tests
+
+**Importance:** High | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- interactionReply
+- non-replyable interactions
+- chat input command interactions
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/player/soundcloudMatcher.spec.ts` — 29 tests
+
+**Importance:** High | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- parseDurationString
+- findMatchingSoundCloudResult – title matching
+- findMatchingSoundCloudResult – duration matching
+
+
+### `utils/music/sessionSnapshots.spec.ts` — 19 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- MusicSessionSnapshotService
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `handlers/player/playerFactory.spec.ts` — 17 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- playerFactory
+- createPlayer
+- YouTube extractor configuration
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/music/buttonComponents.spec.ts` — 16 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- createMusicControlButtons
+- createMusicActionButtons
+- createQueuePaginationButtons
+
+
+### `utils/moderation/modDigestScheduler.spec.ts` — 15 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- ModDigestSchedulerService.isDue
+- ModDigestSchedulerService.tick
+- ModDigestSchedulerService.sendDigestForGuild
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/music/ytdlpExtractor/service.spec.ts` — 15 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- YtDlpExtractorService
+- constructor
+- validate
+
+
+### `utils/general/responseEmbeds/buildListPageEmbed.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- buildListPageEmbed
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/moderation/modDigestConfig.spec.ts` — 13 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- ModDigestConfigService.enable
+- ModDigestConfigService.disable
+- ModDigestConfigService.get
+
+
+### `functions/automod/commands/automod.spec.ts` — 12 tests
+
+**Importance:** Medium | **Coverage impact:** Low | **Approach:** ❌ Suboptimal
+
+**Test groups:**
+- automod command
+- status subcommand
+- preset subcommand — list
+
+**Notes:** Heavy mocking (coordinator tests) — verifies call orchestration but may not catch real behavioral regressions. Consider adding integration tests.
+
+### `utils/general/responseEmbeds/buildUserProfileEmbed.spec.ts` — 11 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ✅ Optimal
+
+**Test groups:**
+- buildUserProfileEmbed
+
+**Notes:** Pure behavioral — excellent coverage of real behavior.
+
+### `utils/command/getCommandsFromDirectory.spec.ts` — 10 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- getCommandsFromDirectory
+
+
+### `services/VoiceChannelStatusService.spec.ts` — 9 tests
+
+**Importance:** Medium | **Coverage impact:** Medium | **Approach:** ⚠️ Acceptable
+
+**Test groups:**
+- VoiceChannelStatusService
+- setTrackStatus
+- clearStatus
+
+
+#### Additional Music Commands tests
+
+- `functions/music/commands/volume.spec.ts` (9 tests, ❌ Suboptimal)
+- `functions/music/commands/artist.spec.ts` (8 tests, ❌ Suboptimal)
+- `functions/music/commands/djrole.spec.ts` (8 tests, ❌ Suboptimal)
+- `functions/music/commands/history.spec.ts` (8 tests, ❌ Suboptimal)
+- `functions/music/commands/queue/index.spec.ts` (8 tests, ❌ Suboptimal)
+- `functions/music/commands/spotify.spec.ts` (8 tests, ❌ Suboptimal)
+- `functions/music/commands/voteskip.spec.ts` (8 tests, ❌ Suboptimal)
+- `functions/music/commands/lyrics.spec.ts` (7 tests, ❌ Suboptimal)
+- `functions/music/commands/leave.spec.ts` (6 tests, ❌ Suboptimal)
+- `functions/music/commands/pause.spec.ts` (6 tests, ❌ Suboptimal)
+- `functions/music/commands/seek.spec.ts` (6 tests, ❌ Suboptimal)
+- `functions/music/commands/clear.spec.ts` (5 tests, ❌ Suboptimal)
+- `functions/music/commands/leavecleanup.spec.ts` (5 tests, ❌ Suboptimal)
+- `functions/music/commands/playlist.spec.ts` (5 tests, ❌ Suboptimal)
+- `functions/music/commands/queue/queueFormatter.spec.ts` (5 tests, ⚠️ Acceptable)
+- `functions/music/commands/recommendation/handlers/feedbackHandler.spec.ts` (5 tests, ❌ Suboptimal)
+- `functions/music/commands/nowplaying.spec.ts` (4 tests, ❌ Suboptimal)
+- `functions/music/commands/recommendation/index.spec.ts` (4 tests, ❌ Suboptimal)
+- `functions/music/commands/replay.spec.ts` (4 tests, ❌ Suboptimal)
+- `functions/music/commands/skipto.spec.ts` (4 tests, ❌ Suboptimal)
+- `functions/music/commands/stop.spec.ts` (4 tests, ❌ Suboptimal)
+- `functions/music/commands/songinfo.spec.ts` (3 tests, ❌ Suboptimal)
+
+#### Additional Utilities tests
+
+- `utils/moderation/digestEmbed.spec.ts` (9 tests, ✅ Optimal)
+- `index.spec.ts` (8 tests, ❌ Suboptimal)
+- `utils/music/sessionStartupRestore.spec.ts` (8 tests, ❌ Suboptimal)
+- `utils/music/voteSkipStore.spec.ts` (7 tests, ✅ Optimal)
+- `services/musicRecommendation/index.spec.ts` (6 tests, ❌ Suboptimal)
+- `utils/general/deferredInteractionReply.spec.ts` (6 tests, ⚠️ Acceptable)
+- `utils/misc/pathUtils.spec.ts` (5 tests, ✅ Optimal)
+- `utils/music/idleDisconnect.spec.ts` (4 tests, ❌ Suboptimal)
+- `scripts/sentryTest.spec.ts` (3 tests, ⚠️ Acceptable)
+- `scripts/sentryTestCli.spec.ts` (2 tests, ⚠️ Acceptable)
+- `utils/music/collaborativePlaylist.spec.ts` (2 tests, ✅ Optimal)
+- `config/constants.spec.ts` (1 tests, ✅ Optimal)
+- `functions/automod/commands/index.spec.ts` (1 tests, ✅ Optimal)
+- `register.spec.ts` (1 tests, ❌ Suboptimal)
+- `functions/general/handlers/reactionroleHandlers.spec.ts` (0 tests, ❌ Suboptimal)
+- `functions/general/handlers/roleconfigHandlers.spec.ts` (0 tests, ❌ Suboptimal)
+- `utils/general/birthdayScheduler.spec.ts` (0 tests, ❌ Suboptimal)
+
+---
+
+## Summary Statistics
+
+**Total files:** 200
+**Total tests:** 2624
+**Total spec lines:** 64117
+
+### By Importance
+- Critical: 4
+- High: 46
+- Medium: 139
+- Low: 11
+
+### By Approach
+- Optimal (✅): 31 files
+- Acceptable (⚠️): 34 files
+- Suboptimal (❌): 135 files
+
+### By Test Count
+- 50+ tests: 7
+- 30-49 tests: 16
+- 15-29 tests: 37
+- 5-14 tests: 80
+- <5 tests: 60
+---
+
+## Key Findings & Recommendations
+
+### 1. Heavy Coordinator Test Bias (135/200 files = 67%)
+
+**Finding:** Most of Lucky's test suite relies on heavy mocking (jest.mock + jest.fn), making them **coordinator tests** that verify the right calls are made but don't test actual behavior.
+
+**Examples:**
+- `queueManipulation.spec.ts` (114 tests, 225 mocks) — verifies replenish/shuffle/rescue call the right dependencies, but doesn't verify queue state changes in isolation
+- `playerFactory.bridge.spec.ts` (40 tests, 23 mocks) — verifies yt-dlp/play-dl are called correctly, but subprocess execution isn't truly tested
+- `play/index.spec.ts` (23 tests, 30+ mocks) — tests command orchestration, not music search/play behavior
+
+**Risk:** A regression in actual behavior (e.g., queue corruption, duplicate track logic) could be silently hidden by passing mocks.
+
+**Recommendation:**
+- Add 10-15 **behavioral integration tests** for critical paths (e.g., queue replenish actually yields valid candidates, play command finds tracks in real scenarios)
+- Gradually convert heavy coordinator tests to test through public APIs instead of mocking every dependency
+
+### 2. Low Coverage in Critical Domains
+
+**Finding:** Core music playback has only 63 tests across 4 files, all heavily mocked. Autoplay (core recommendation logic) is fragmented across 13 files with mixed approaches.
+
+**Critical gaps:**
+- No integration test for "user plays song → autoplay replenishes queue → next track plays"
+- No test for cascade failures (Last.fm down → falls back to Spotify → falls back to YouTube)
+- No test for queue state consistency after player errors
+
+**Recommendation:**
+- Create 5-10 **happy-path integration tests** that exercise the full flow from user action to queue state
+- Add **error injection tests** (mock provider timeouts, API 503s) to verify fallback chains work
+
+### 3. Optimal Test Files (31/200 = 15%)
+
+**High-value files** with pure behavioral tests (no mocks):
+- `searchQueryCleaner.spec.ts` (96 tests) — excellent, pure logic
+- `spotifyApi.spec.ts` (57 tests) — fetch mocked at boundary, not dependencies
+- `sessionMood.spec.ts` (36 tests) — pure mood detection logic
+- `youtubeErrorHandler/analyzer.spec.ts` (56 tests) — pure error parsing
+
+**Action:** Use these as reference patterns for refactoring coordinator tests.
+
+### 4. Minimal/Trivial Tests (60/200 = 30%)
+
+**Finding:** 60 files have <5 tests; many are smoke tests or single-assertion wrappers:
+- `watchdog.rejoin.spec.ts` (2 tests)
+- `queueResolver.guard.spec.ts` (1 test)
+- `register.spec.ts` (1 test)
+
+**Recommendation:**
+- Delete or consolidate tests that only verify "function exists and doesn't crash"
+- Merge trivial tests into integration suites
+- Expected cleanup: remove ~20-30 files, consolidate into 3-5 core integration suites
+
+### 5. Suboptimal Approaches by Domain
+
+| Domain | Status | Action |
+|--------|--------|--------|
+| Core Music Playback | ❌ All mocked | Add integration tests for player lifecycle |
+| Queue Management | ❌ Mostly mocked | Refactor queueManipulation to behavioral; test via public API |
+| Music Commands | ❌ Mostly mocked | Create command integration tests; mock only Discord API |
+| Autoplay System | ⚠️ Mixed | Good unit tests; add integration for recommendation flow |
+| Track Metadata | ⚠️ Mixed | Good; titleComparison/duplicateDetection are solid |
+| Spotify/Last.fm Integration | ✅ Mostly good | Maintain fetch mocking approach; add error scenarios |
+
+### 6. Recommendation for Next Steps
+
+**Phase 1 (Quick wins, ~1 week):**
+1. Delete 20-30 trivial single-assertion tests (queueResolver.guard.spec.ts, register.spec.ts, etc.)
+2. Consolidate smoke tests into integration suites
+3. Document "test pattern anti-patterns" (coordinator-only tests, log assertions)
+
+**Phase 2 (Medium-term, ~2-3 weeks):**
+1. Create 10-15 behavioral integration tests for critical flows:
+ - Play command → search → add to queue → autoplay replenish
+ - Queue manipulation → state consistency
+ - Autoplay scoring → candidate selection
+2. Refactor `queueManipulation.spec.ts` to test via public API (currently 225 mocks)
+3. Add error injection tests for provider fallbacks
+
+**Phase 3 (Long-term, ~monthly):**
+1. Gradually convert coordinator tests to hybrid/behavioral by reducing mock count
+2. Establish "max mocks per test file" guideline (e.g., <10)
+3. Bi-weekly review of new tests to maintain quality
+
+### 7. By the Numbers
+
+- **Optimal approach (✅):** 31 files, ~15% coverage
+- **Acceptable approach (⚠️):** 34 files, ~17% coverage
+- **Suboptimal approach (❌):** 135 files, **68% coverage** ← needs focus
+- **Total test debt:** ~6000 lines of coordinator-only tests that don't catch behavioral regressions
+
+---
+
+## Conclusion
+
+Lucky has a **solid quantity of tests (2624 total)** but **skews heavily toward coordinator tests that don't catch behavioral regressions**. The test suite would benefit more from 20-30 **well-designed integration tests** than 100 additional mocked unit tests.
+
+**Priority:** Convert critical domains (Queue Management, Core Playback, Autoplay) to behavioral + integration approach. Expected ROI: 3-5x better regression detection with 20% fewer total tests.
+
diff --git a/packages/bot/jest.config.cjs b/packages/bot/jest.config.cjs
index 6cf17d99c..ea0bfb065 100644
--- a/packages/bot/jest.config.cjs
+++ b/packages/bot/jest.config.cjs
@@ -11,6 +11,18 @@ module.exports = {
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
+ // Floor pinned to the round-down of the post-#821 baseline
+ // (stmts 67 / branches 63 / functions 63 / lines 68). Binding without
+ // forcing emergency work; tighten 2-3 % per cleanup phase as the suite
+ // shrinks. See .agents/plans/test-cleanup-phase2.md.
+ coverageThreshold: {
+ global: {
+ statements: 65,
+ branches: 60,
+ functions: 60,
+ lines: 65,
+ },
+ },
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^chalk$': '/tests/__mocks__/chalk.ts',
diff --git a/packages/bot/package.json b/packages/bot/package.json
index 9214c05f8..258d3c46b 100644
--- a/packages/bot/package.json
+++ b/packages/bot/package.json
@@ -1,6 +1,6 @@
{
"name": "@lucky/bot",
- "version": "2.9.0",
+ "version": "2.10.0",
"description": "Discord bot application",
"type": "module",
"main": "./dist/index.js",
@@ -36,9 +36,9 @@
},
"devDependencies": {
"@types/jest": "^30.0.0",
- "@types/node": "^25.6.0",
+ "@types/node": "^25.6.2",
"@types/ws": "^8.5.13",
- "jest": "^30.2.0",
+ "jest": "^30.4.2",
"ts-jest": "^29.4.9",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
diff --git a/packages/bot/src/bot/start/initializer.spec.ts b/packages/bot/src/bot/start/initializer.spec.ts
index 373ceb62b..2f691c700 100644
--- a/packages/bot/src/bot/start/initializer.spec.ts
+++ b/packages/bot/src/bot/start/initializer.spec.ts
@@ -243,36 +243,6 @@ describe('BotInitializer', () => {
expect(state.isReady).toBe(false)
})
- it('logs success on clean shutdown', async () => {
- const initResult = await initializer.initializeBot()
- expect(initResult.success).toBe(true)
-
- await initializer.shutdown()
-
- expect(infoLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Bot shutdown completed',
- })
- )
- })
-
- it('handles errors during shutdown gracefully', async () => {
- const initResult = await initializer.initializeBot()
- expect(initResult.success).toBe(true)
-
- const client = initializer.getClient()
- const destroyError = new Error('Destroy failed')
- ;(client?.destroy as jest.Mock).mockRejectedValue(destroyError)
-
- await initializer.shutdown()
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Error during bot shutdown'),
- })
- )
- })
-
it('silently succeeds when shutdown called with no client', async () => {
expect(() => initializer.shutdown()).not.toThrow()
})
diff --git a/packages/bot/src/config/constants.spec.ts b/packages/bot/src/config/constants.spec.ts
deleted file mode 100644
index 032e0f11e..000000000
--- a/packages/bot/src/config/constants.spec.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { describe, expect, it } from '@jest/globals'
-import { COMMAND_CATEGORIES } from './constants'
-
-describe('COMMAND_CATEGORIES', () => {
- it('declares all supported command categories', () => {
- const byName = (a: string, b: string) => a.localeCompare(b)
- expect(Object.keys(COMMAND_CATEGORIES).sort(byName)).toEqual(
- ['automod', 'download', 'general', 'management', 'moderation', 'music'].sort(byName),
- )
- expect(COMMAND_CATEGORIES.management.prefixes).toContain('guildconfig')
- expect(COMMAND_CATEGORIES.management.prefixes).toContain('serversetup')
- expect(COMMAND_CATEGORIES.automod.prefixes).toEqual(['automod'])
- })
-})
diff --git a/packages/bot/src/functions/general/commands/giveaway.spec.ts b/packages/bot/src/functions/general/commands/giveaway.spec.ts
index a16b63d04..9c71fe178 100644
--- a/packages/bot/src/functions/general/commands/giveaway.spec.ts
+++ b/packages/bot/src/functions/general/commands/giveaway.spec.ts
@@ -100,47 +100,6 @@ describe('giveaway command', () => {
})
})
- it('should start a giveaway', async () => {
- const interaction = createInteraction('start', { value: '1h' }) as any
- interaction.options.getString = jest
- .fn()
- .mockReturnValueOnce('1h')
- .mockReturnValueOnce('Discord Nitro')
-
- await giveawayCommand.execute({
- interaction,
- })
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should reject invalid duration', async () => {
- const interaction = createInteraction('start') as any
- interaction.options.getString = jest.fn().mockReturnValueOnce('invalid')
-
- await giveawayCommand.execute({
- interaction,
- })
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should handle end subcommand', async () => {
- await giveawayCommand.execute({
- interaction: createInteraction('end') as any,
- })
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should handle reroll subcommand', async () => {
- await giveawayCommand.execute({
- interaction: createInteraction('reroll') as any,
- })
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
it('starts giveaway when channel is text-based', async () => {
jest.useFakeTimers()
const sendMock = jest.fn().mockResolvedValue({
diff --git a/packages/bot/src/functions/general/commands/level.spec.ts b/packages/bot/src/functions/general/commands/level.spec.ts
index 66bc8b187..198fdb39d 100644
--- a/packages/bot/src/functions/general/commands/level.spec.ts
+++ b/packages/bot/src/functions/general/commands/level.spec.ts
@@ -167,14 +167,6 @@ describe('level command', () => {
expect(createSuccessEmbedMock).toHaveBeenCalledWith('Reward Removed', expect.any(String))
})
- it('returns early without guild', async () => {
- requireGuildMock.mockResolvedValue(false)
- await levelCommand.execute({
- interaction: { ...createInteraction('rank'), guild: null },
- } as any)
- expect(getMemberXPMock).not.toHaveBeenCalled()
- })
-
it('shows error embed on service failure', async () => {
getMemberXPMock.mockRejectedValue(new Error('DB error'))
getRankMock.mockRejectedValue(new Error('DB error'))
diff --git a/packages/bot/src/functions/general/commands/starboard.spec.ts b/packages/bot/src/functions/general/commands/starboard.spec.ts
deleted file mode 100644
index 5c832bd07..000000000
--- a/packages/bot/src/functions/general/commands/starboard.spec.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import starboardCommand from './starboard'
-
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn((title: string, description: string) => ({ type: 'success', title, description }))
-const createErrorEmbedMock = jest.fn((title: string, description: string) => ({ type: 'error', title, description }))
-const createInfoEmbedMock = jest.fn((title: string, description: string) => ({ type: 'info', title, description }))
-const buildListPageEmbedMock = jest.fn((items: unknown, page: unknown, config: unknown) => ({ type: 'listpage', items, page, config }))
-const requireGuildMock = jest.fn()
-const getConfigMock = jest.fn()
-const upsertConfigMock = jest.fn()
-const deleteConfigMock = jest.fn()
-const getTopEntriesMock = jest.fn()
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
- createInfoEmbed: (...args: unknown[]) => createInfoEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/general/responseEmbeds', () => ({
- buildListPageEmbed: (...args: unknown[]) => buildListPageEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
-}))
-
-jest.mock('@lucky/shared/services', () => ({
- starboardService: {
- getConfig: (...args: unknown[]) => getConfigMock(...args),
- upsertConfig: (...args: unknown[]) => upsertConfigMock(...args),
- deleteConfig: (...args: unknown[]) => deleteConfigMock(...args),
- getTopEntries: (...args: unknown[]) => getTopEntriesMock(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: jest.fn(),
- infoLog: jest.fn(),
- debugLog: jest.fn(),
-}))
-
-function createInteraction(subcommand: string, opts: Record = {}) {
- return {
- guild: { id: 'guild-1' },
- options: {
- getSubcommand: jest.fn(() => subcommand),
- getSubcommandGroup: jest.fn(() => null),
- getChannel: jest.fn((name: string) => opts[name] ?? null),
- getString: jest.fn((name: string) => opts[name] ?? null),
- getInteger: jest.fn((name: string) => opts[name] ?? null),
- getBoolean: jest.fn((name: string) => opts[name] ?? null),
- },
- } as any
-}
-
-describe('starboard command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- })
-
- it('setup creates starboard config with defaults', async () => {
- const channel = { id: 'ch-1' }
- upsertConfigMock.mockResolvedValue({})
- await starboardCommand.execute({
- interaction: createInteraction('setup', { channel }),
- } as any)
- expect(upsertConfigMock).toHaveBeenCalledWith('guild-1', {
- channelId: 'ch-1',
- emoji: '⭐',
- threshold: 3,
- selfStar: false,
- })
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('Starboard Configured', expect.any(String))
- })
-
- it('setup accepts custom emoji and threshold', async () => {
- const channel = { id: 'ch-2' }
- upsertConfigMock.mockResolvedValue({})
- await starboardCommand.execute({
- interaction: createInteraction('setup', { channel, emoji: '🌟', threshold: 5, 'self-star': true }),
- } as any)
- expect(upsertConfigMock).toHaveBeenCalledWith('guild-1', {
- channelId: 'ch-2',
- emoji: '🌟',
- threshold: 5,
- selfStar: true,
- })
- })
-
- it('disable calls deleteConfig', async () => {
- deleteConfigMock.mockResolvedValue(undefined)
- await starboardCommand.execute({ interaction: createInteraction('disable') } as any)
- expect(deleteConfigMock).toHaveBeenCalledWith('guild-1')
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('Starboard Disabled', expect.any(String))
- })
-
- it('top shows entries when they exist', async () => {
- getTopEntriesMock.mockResolvedValue([
- { guildId: 'guild-1', channelId: 'ch-1', messageId: 'msg-1', starCount: 10 },
- ])
- await starboardCommand.execute({ interaction: createInteraction('top') } as any)
- expect(buildListPageEmbedMock).toHaveBeenCalledWith(
- expect.arrayContaining([
- expect.objectContaining({
- value: expect.stringContaining('Jump to message'),
- }),
- ]),
- 1,
- expect.objectContaining({ title: 'Top Starred Messages' }),
- )
- })
-
- it('top shows empty state when no entries', async () => {
- getTopEntriesMock.mockResolvedValue([])
- await starboardCommand.execute({ interaction: createInteraction('top') } as any)
- expect(buildListPageEmbedMock).toHaveBeenCalledWith(
- [],
- 1,
- expect.objectContaining({
- title: 'Top Starred Messages',
- emptyMessage: 'No starred messages yet.',
- }),
- )
- })
-
- it('status shows config when set', async () => {
- getConfigMock.mockResolvedValue({
- channelId: 'ch-99',
- emoji: '⭐',
- threshold: 3,
- selfStar: false,
- })
- await starboardCommand.execute({ interaction: createInteraction('status') } as any)
- expect(createInfoEmbedMock).toHaveBeenCalledWith('Starboard Status', expect.stringContaining('ch-99'))
- })
-
- it('status shows not configured when no config', async () => {
- getConfigMock.mockResolvedValue(null)
- await starboardCommand.execute({ interaction: createInteraction('status') } as any)
- expect(createInfoEmbedMock).toHaveBeenCalledWith('Starboard Status', expect.stringContaining('not configured'))
- })
-
- it('returns early without guild', async () => {
- requireGuildMock.mockResolvedValue(false)
- await starboardCommand.execute({
- interaction: { ...createInteraction('setup'), guild: null },
- } as any)
- expect(upsertConfigMock).not.toHaveBeenCalled()
- })
-
- it('shows error embed on service failure', async () => {
- upsertConfigMock.mockRejectedValue(new Error('DB error'))
- const channel = { id: 'ch-1' }
- await starboardCommand.execute({
- interaction: createInteraction('setup', { channel }),
- } as any)
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Error', 'DB error')
- })
-})
diff --git a/packages/bot/src/functions/general/commands/version.spec.ts b/packages/bot/src/functions/general/commands/version.spec.ts
deleted file mode 100644
index 4a3bf1749..000000000
--- a/packages/bot/src/functions/general/commands/version.spec.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { describe, it, expect, jest, beforeEach, afterAll } from '@jest/globals'
-
-const ORIGINAL_NPM_PACKAGE_VERSION = process.env.npm_package_version
-
-const readFileSyncMock = jest.fn<(...args: unknown[]) => string>(() =>
- JSON.stringify({ version: '9.9.9' }),
-)
-
-const deferReplyMock = jest.fn<(arg?: unknown) => Promise>(
- async (_arg?: unknown) => undefined,
-)
-const editReplyMock = jest.fn<(arg?: unknown) => Promise>(
- async (_arg?: unknown) => undefined,
-)
-const createInfoEmbedMock = jest.fn((title: string, message: string) => ({
- title,
- message,
-}))
-const interactionReplyMock = jest.fn()
-
-jest.mock('node:fs', () => ({
- readFileSync: (...args: unknown[]) => readFileSyncMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createInfoEmbed: (...args: unknown[]) =>
- createInfoEmbedMock(...(args as [string, string])),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-import versionCommand from './version'
-
-function createInteraction() {
- return {
- deferReply: deferReplyMock,
- editReply: editReplyMock,
- } as any
-}
-
-describe('version command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- readFileSyncMock.mockReturnValue(JSON.stringify({ version: '9.9.9' }))
- delete process.env.COMMIT_SHA
- delete process.env.npm_package_version
- })
-
- it('defers reply as ephemeral', async () => {
- const interaction = createInteraction()
- await versionCommand.execute({ interaction } as any)
- expect(deferReplyMock).toHaveBeenCalledWith({ flags: 64 })
- })
-
- it('replies with version from package.json', async () => {
- const interaction = createInteraction()
- await versionCommand.execute({ interaction } as any)
- expect(createInfoEmbedMock).toHaveBeenCalledWith(
- 'Bot Version',
- 'v9.9.9',
- )
- })
-
- it('falls back to commit sha when package.json cannot be read', async () => {
- readFileSyncMock.mockImplementation(() => {
- throw new Error('missing package.json')
- })
- process.env.COMMIT_SHA = 'abc123def456'
-
- const interaction = createInteraction()
- await versionCommand.execute({ interaction } as any)
-
- expect(createInfoEmbedMock).toHaveBeenCalledWith(
- 'Bot Version',
- 'commit abc123d',
- )
- })
-
- it('prefers npm_package_version when available', async () => {
- process.env.npm_package_version = '2.6.62'
-
- const interaction = createInteraction()
- await versionCommand.execute({ interaction } as any)
-
- expect(readFileSyncMock).not.toHaveBeenCalled()
- expect(createInfoEmbedMock).toHaveBeenCalledWith(
- 'Bot Version',
- 'v2.6.62',
- )
- })
-})
-
-afterAll(() => {
- if (ORIGINAL_NPM_PACKAGE_VERSION === undefined) {
- delete process.env.npm_package_version
- return
- }
- process.env.npm_package_version = ORIGINAL_NPM_PACKAGE_VERSION
-})
diff --git a/packages/bot/src/functions/general/handlers/twitchHandlers.spec.ts b/packages/bot/src/functions/general/handlers/twitchHandlers.spec.ts
deleted file mode 100644
index d4367d280..000000000
--- a/packages/bot/src/functions/general/handlers/twitchHandlers.spec.ts
+++ /dev/null
@@ -1,348 +0,0 @@
-import {
- afterEach,
- beforeEach,
- describe,
- expect,
- it,
- jest,
-} from '@jest/globals'
-import type {
- ChatInputCommandInteraction,
- TextBasedChannel,
- Guild,
- User,
-} from 'discord.js'
-
-const interactionReplyMock = jest.fn()
-const twitchNotificationServiceMock = jest.fn()
-const getPrismaClientMock = jest.fn()
-const getTwitchUserByLoginMock = jest.fn()
-const refreshTwitchSubscriptionsMock = jest.fn()
-const createErrorEmbedMock = jest.fn()
-const createSuccessEmbedMock = jest.fn()
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: interactionReplyMock,
-}))
-
-jest.mock('@lucky/shared/services', () => {
- const addMock = jest.fn()
- const removeMock = jest.fn()
- const listMock = jest.fn()
- return {
- twitchNotificationService: {
- add: addMock,
- remove: removeMock,
- listByGuild: listMock,
- },
- }
-})
-
-jest.mock('@lucky/shared/utils', () => ({
- getPrismaClient: getPrismaClientMock,
- errorLog: jest.fn(),
- infoLog: jest.fn(),
-}))
-
-jest.mock('../../../twitch/twitchApi', () => ({
- getTwitchUserByLogin: getTwitchUserByLoginMock,
-}))
-
-jest.mock('../../../twitch', () => ({
- refreshTwitchSubscriptions: refreshTwitchSubscriptionsMock,
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createErrorEmbed: createErrorEmbedMock,
- createSuccessEmbed: createSuccessEmbedMock,
-}))
-
-import { twitchNotificationService } from '@lucky/shared/services'
-
-import {
- handleTwitchAdd,
- handleTwitchRemove,
- handleTwitchList,
-} from './twitchHandlers'
-
-describe('twitchHandlers', () => {
- let mockInteraction: Partial
- let mockGuild: Partial
- let mockUser: Partial
- let mockChannel: Partial
- let mockPrisma: any
- let mockGetString: jest.Mock
- let mockGetChannel: jest.Mock
-
- beforeEach(() => {
- jest.clearAllMocks()
-
- mockUser = {
- id: 'user123',
- }
-
- mockChannel = {
- id: 'channel123',
- }
-
- mockGuild = {
- id: 'guild123',
- name: 'Test Guild',
- }
-
- mockPrisma = {
- guild: {
- findUnique: jest.fn(),
- create: jest.fn(),
- },
- }
-
- mockGetString = jest.fn()
- mockGetChannel = jest.fn()
-
- mockInteraction = {
- guild: mockGuild as Guild,
- user: mockUser as User,
- channel: mockChannel as TextBasedChannel,
- options: {
- getString: mockGetString,
- getChannel: mockGetChannel,
- },
- }
-
- getPrismaClientMock.mockReturnValue(mockPrisma)
- createErrorEmbedMock.mockReturnValue({} as any)
- createSuccessEmbedMock.mockReturnValue({} as any)
- })
-
- afterEach(() => {
- jest.clearAllMocks()
- })
-
- describe('handleTwitchAdd', () => {
- beforeEach(() => {
- mockGetString.mockReturnValue('testuser')
- mockGetChannel.mockReturnValue(null)
- mockPrisma.guild.findUnique.mockResolvedValue({ id: 'guildDbId' })
- jest.mocked(twitchNotificationService.add).mockResolvedValue(true)
- })
-
- it('should add a twitch notification successfully', async () => {
- const twitchUser = {
- id: 'twitch123',
- login: 'testuser',
- display_name: 'TestUser',
- }
- getTwitchUserByLoginMock.mockResolvedValue(twitchUser)
- jest.mocked(twitchNotificationService.add).mockResolvedValue(true)
-
- await handleTwitchAdd(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(getTwitchUserByLoginMock).toHaveBeenCalledWith('testuser')
- expect(refreshTwitchSubscriptionsMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should trim and lowercase username', async () => {
- mockGetString.mockReturnValue(' TestUser ')
- const twitchUser = {
- id: 'twitch123',
- login: 'testuser',
- display_name: 'TestUser',
- }
- getTwitchUserByLoginMock.mockResolvedValue(twitchUser)
- jest.mocked(twitchNotificationService.add).mockResolvedValue(true)
-
- await handleTwitchAdd(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(getTwitchUserByLoginMock).toHaveBeenCalledWith('testuser')
- })
-
- it('should use channel option if provided', async () => {
- const optionChannel = { id: 'option-channel-123' }
- mockGetChannel.mockReturnValue(optionChannel as any)
- const twitchUser = {
- id: 'twitch123',
- login: 'testuser',
- display_name: 'TestUser',
- }
- getTwitchUserByLoginMock.mockResolvedValue(twitchUser)
- jest.mocked(twitchNotificationService.add).mockResolvedValue(true)
-
- await handleTwitchAdd(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should reply with error if twitch user not found', async () => {
- getTwitchUserByLoginMock.mockResolvedValue(null)
-
- await handleTwitchAdd(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should create guild if it does not exist', async () => {
- mockPrisma.guild.findUnique.mockResolvedValue(null)
- mockPrisma.guild.create.mockResolvedValue({ id: 'newGuildId' })
- const twitchUser = {
- id: 'twitch123',
- login: 'testuser',
- display_name: 'TestUser',
- }
- getTwitchUserByLoginMock.mockResolvedValue(twitchUser)
- jest.mocked(twitchNotificationService.add).mockResolvedValue(true)
-
- await handleTwitchAdd(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(mockPrisma.guild.create).toHaveBeenCalled()
- })
-
- it('should reply with error if notification add fails', async () => {
- const twitchUser = {
- id: 'twitch123',
- login: 'testuser',
- display_name: 'TestUser',
- }
- getTwitchUserByLoginMock.mockResolvedValue(twitchUser)
- jest.mocked(twitchNotificationService.add).mockResolvedValue(false)
-
- await handleTwitchAdd(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(refreshTwitchSubscriptionsMock).not.toHaveBeenCalled()
- })
- })
-
- describe('handleTwitchRemove', () => {
- beforeEach(() => {
- mockGetString.mockReturnValue('testuser')
- mockPrisma.guild.findUnique.mockResolvedValue({ id: 'guildDbId' })
- jest.mocked(twitchNotificationService.remove).mockResolvedValue(
- true,
- )
- })
-
- it('should remove a twitch notification successfully', async () => {
- const twitchUser = {
- id: 'twitch123',
- login: 'testuser',
- display_name: 'TestUser',
- }
- getTwitchUserByLoginMock.mockResolvedValue(twitchUser)
- jest.mocked(twitchNotificationService.remove).mockResolvedValue(
- true,
- )
-
- await handleTwitchRemove(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(refreshTwitchSubscriptionsMock).toHaveBeenCalled()
- })
-
- it('should reply with error if twitch user not found', async () => {
- getTwitchUserByLoginMock.mockResolvedValue(null)
-
- await handleTwitchRemove(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should reply with error if guild does not exist', async () => {
- const twitchUser = {
- id: 'twitch123',
- login: 'testuser',
- display_name: 'TestUser',
- }
- getTwitchUserByLoginMock.mockResolvedValue(twitchUser)
- mockPrisma.guild.findUnique.mockResolvedValue(null)
-
- await handleTwitchRemove(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should reply with error if removal fails', async () => {
- const twitchUser = {
- id: 'twitch123',
- login: 'testuser',
- display_name: 'TestUser',
- }
- getTwitchUserByLoginMock.mockResolvedValue(twitchUser)
- jest.mocked(twitchNotificationService.remove).mockResolvedValue(
- false,
- )
-
- await handleTwitchRemove(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(refreshTwitchSubscriptionsMock).not.toHaveBeenCalled()
- })
- })
-
- describe('handleTwitchList', () => {
- beforeEach(() => {
- mockPrisma.guild.findUnique.mockResolvedValue({ id: 'guildDbId' })
- jest.mocked(
- twitchNotificationService.listByGuild,
- ).mockResolvedValue([])
- })
-
- it('should reply with empty list if guild does not exist', async () => {
- mockPrisma.guild.findUnique.mockResolvedValue(null)
-
- await handleTwitchList(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should reply with empty list if no notifications found', async () => {
- jest.mocked(
- twitchNotificationService.listByGuild,
- ).mockResolvedValue([])
-
- await handleTwitchList(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should list all twitch notifications for guild', async () => {
- const notifications = [
- { twitchLogin: 'user1', discordChannelId: 'channel1' },
- { twitchLogin: 'user2', discordChannelId: 'channel2' },
- ]
- jest.mocked(
- twitchNotificationService.listByGuild,
- ).mockResolvedValue(notifications as any)
-
- await handleTwitchList(
- mockInteraction as ChatInputCommandInteraction,
- )
-
- expect(interactionReplyMock).toHaveBeenCalled()
- })
- })
-})
diff --git a/packages/bot/src/functions/management/handlers/automessageHandlers.spec.ts b/packages/bot/src/functions/management/handlers/automessageHandlers.spec.ts
deleted file mode 100644
index 9ce370f69..000000000
--- a/packages/bot/src/functions/management/handlers/automessageHandlers.spec.ts
+++ /dev/null
@@ -1,633 +0,0 @@
-import { describe, expect, it, jest, beforeEach } from '@jest/globals'
-import type { ChatInputCommandInteraction, GuildChannel } from 'discord.js'
-import {
- handleAutoMessageConfig,
- handleAutoMessageList,
-} from './automessageHandlers.js'
-
-const getWelcomeMessageMock = jest.fn()
-const getLeaveMessageMock = jest.fn()
-const getMessagesByTypeMock = jest.fn()
-const createMessageMock = jest.fn()
-const updateMessageMock = jest.fn()
-const infoLogMock = jest.fn()
-const interactionReplyMock = jest.fn()
-
-jest.mock('@lucky/shared/services', () => ({
- autoMessageService: {
- getWelcomeMessage: (...args: unknown[]) =>
- getWelcomeMessageMock(...args),
- getLeaveMessage: (...args: unknown[]) => getLeaveMessageMock(...args),
- getMessagesByType: (...args: unknown[]) =>
- getMessagesByTypeMock(...args),
- createMessage: (...args: unknown[]) => createMessageMock(...args),
- updateMessage: (...args: unknown[]) => updateMessageMock(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- infoLog: (...args: unknown[]) => infoLogMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply.js', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-function createChannel(id: string): GuildChannel {
- return {
- id,
- toString: () => `<#${id}>`,
- } as unknown as GuildChannel
-}
-
-function createInteraction(
- options: {
- enabled?: boolean
- channel?: GuildChannel | null
- message?: string | null
- } = {},
-): ChatInputCommandInteraction {
- return {
- guild: { id: 'guild-123', name: 'Test Guild' },
- user: { tag: 'TestUser#1234' },
- options: {
- getBoolean: jest.fn().mockReturnValue(options.enabled ?? true),
- getChannel: jest.fn().mockReturnValue(options.channel ?? null),
- getString: jest.fn().mockReturnValue(options.message ?? null),
- },
- } as unknown as ChatInputCommandInteraction
-}
-
-function createExistingMessage(overrides = {}) {
- return {
- id: 'msg-123',
- guildId: 'guild-123',
- type: 'welcome',
- enabled: true,
- channelId: 'channel-456',
- message: 'Welcome {user} to {server}!',
- ...overrides,
- }
-}
-
-describe('handleAutoMessageConfig', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- interactionReplyMock.mockResolvedValue(undefined)
- getWelcomeMessageMock.mockResolvedValue(null)
- getLeaveMessageMock.mockResolvedValue(null)
- createMessageMock.mockResolvedValue(undefined)
- updateMessageMock.mockResolvedValue(undefined)
- })
-
- it('rejects enabling without channel and message when no existing config', async () => {
- const interaction = createInteraction({ enabled: true })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(getWelcomeMessageMock).toHaveBeenCalledWith('guild-123')
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- content:
- '❌ Please provide a channel and message to enable this feature.',
- },
- })
- expect(createMessageMock).not.toHaveBeenCalled()
- expect(updateMessageMock).not.toHaveBeenCalled()
- })
-
- it('creates new welcome message when enabled with both channel and message', async () => {
- const channel = createChannel('channel-789')
- const interaction = createInteraction({
- enabled: true,
- channel,
- message: 'Hello {user}!',
- })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(createMessageMock).toHaveBeenCalledWith(
- 'guild-123',
- 'welcome',
- { message: 'Hello {user}!' },
- { channelId: 'channel-789' },
- )
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- color: 0x51cf66,
- title: '✅ Welcome Messages Enabled',
- }),
- }),
- ],
- },
- })
- expect(infoLogMock).toHaveBeenCalledWith({
- message: 'welcome messages enabled by TestUser#1234 in Test Guild',
- })
- })
-
- it('creates new leave message when enabled with both channel and message', async () => {
- const channel = createChannel('channel-999')
- const interaction = createInteraction({
- enabled: true,
- channel,
- message: 'Goodbye {user}!',
- })
-
- await handleAutoMessageConfig(interaction, 'leave')
-
- expect(createMessageMock).toHaveBeenCalledWith(
- 'guild-123',
- 'leave',
- { message: 'Goodbye {user}!' },
- { channelId: 'channel-999' },
- )
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- color: 0x51cf66,
- title: '✅ Leave Messages Enabled',
- }),
- }),
- ],
- },
- })
- })
-
- it('updates existing message when enabled with both channel and message', async () => {
- const existing = createExistingMessage()
- getWelcomeMessageMock.mockResolvedValue(existing)
- const channel = createChannel('channel-new')
- const interaction = createInteraction({
- enabled: true,
- channel,
- message: 'Updated message',
- })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(updateMessageMock).toHaveBeenCalledWith('msg-123', {
- enabled: true,
- message: 'Updated message',
- channelId: 'channel-new',
- })
- expect(createMessageMock).not.toHaveBeenCalled()
- })
-
- it('updates existing message with only channel when message not provided', async () => {
- const existing = createExistingMessage()
- getWelcomeMessageMock.mockResolvedValue(existing)
- const channel = createChannel('channel-updated')
- const interaction = createInteraction({
- enabled: true,
- channel,
- message: null,
- })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(updateMessageMock).toHaveBeenCalledWith('msg-123', {
- enabled: true,
- channelId: 'channel-updated',
- })
- })
-
- it('updates existing message with only message when channel not provided', async () => {
- const existing = createExistingMessage()
- getLeaveMessageMock.mockResolvedValue(existing)
- const interaction = createInteraction({
- enabled: true,
- channel: null,
- message: 'Updated text',
- })
-
- await handleAutoMessageConfig(interaction, 'leave')
-
- expect(updateMessageMock).toHaveBeenCalledWith('msg-123', {
- enabled: true,
- message: 'Updated text',
- })
- })
-
- it('rejects partial update when no existing config', async () => {
- const channel = createChannel('channel-123')
- const interaction = createInteraction({
- enabled: true,
- channel,
- message: null,
- })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- content:
- '❌ Please provide both channel and message for the first setup.',
- },
- })
- expect(createMessageMock).not.toHaveBeenCalled()
- expect(updateMessageMock).not.toHaveBeenCalled()
- })
-
- it('disables existing welcome message', async () => {
- const existing = createExistingMessage()
- getWelcomeMessageMock.mockResolvedValue(existing)
- const interaction = createInteraction({ enabled: false })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(updateMessageMock).toHaveBeenCalledWith('msg-123', {
- enabled: false,
- })
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- color: 0xc92a2a,
- title: '❌ Welcome Messages Disabled',
- }),
- }),
- ],
- },
- })
- expect(infoLogMock).toHaveBeenCalledWith({
- message: 'welcome messages disabled by TestUser#1234 in Test Guild',
- })
- })
-
- it('disables existing leave message', async () => {
- const existing = createExistingMessage({ type: 'leave' })
- getLeaveMessageMock.mockResolvedValue(existing)
- const interaction = createInteraction({ enabled: false })
-
- await handleAutoMessageConfig(interaction, 'leave')
-
- expect(updateMessageMock).toHaveBeenCalledWith('msg-123', {
- enabled: false,
- })
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- color: 0xc92a2a,
- title: '❌ Leave Messages Disabled',
- }),
- }),
- ],
- },
- })
- })
-
- it('does nothing when disabling non-existent message', async () => {
- const interaction = createInteraction({ enabled: false })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(updateMessageMock).not.toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- color: 0xc92a2a,
- title: '❌ Welcome Messages Disabled',
- }),
- }),
- ],
- },
- })
- })
-
- it('truncates long messages in embed preview', async () => {
- const channel = createChannel('channel-123')
- const longMessage = 'x'.repeat(150)
- const interaction = createInteraction({
- enabled: true,
- channel,
- message: longMessage,
- })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- fields: expect.arrayContaining([
- expect.objectContaining({
- name: 'Message',
- value: 'x'.repeat(97) + '...',
- }),
- ]),
- }),
- }),
- ],
- },
- })
- })
-
- it('shows full message in embed when under 100 chars', async () => {
- const channel = createChannel('channel-123')
- const shortMessage = 'Welcome {user}!'
- const interaction = createInteraction({
- enabled: true,
- channel,
- message: shortMessage,
- })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- fields: expect.arrayContaining([
- expect.objectContaining({
- name: 'Message',
- value: shortMessage,
- }),
- ]),
- }),
- }),
- ],
- },
- })
- })
-
- it('enables existing message without changing channel or message', async () => {
- const existing = createExistingMessage({ enabled: false })
- getWelcomeMessageMock.mockResolvedValue(existing)
- const interaction = createInteraction({ enabled: true })
-
- await handleAutoMessageConfig(interaction, 'welcome')
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- color: 0x51cf66,
- title: '✅ Welcome Messages Enabled',
- }),
- }),
- ],
- },
- })
- })
-})
-
-describe('handleAutoMessageList', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- interactionReplyMock.mockResolvedValue(undefined)
- getMessagesByTypeMock.mockResolvedValue([])
- })
-
- it('displays empty state when no auto-messages configured', async () => {
- const interaction = createInteraction()
-
- await handleAutoMessageList(interaction)
-
- expect(getMessagesByTypeMock).toHaveBeenCalledWith(
- 'guild-123',
- 'welcome',
- )
- expect(getMessagesByTypeMock).toHaveBeenCalledWith('guild-123', 'leave')
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: { content: '📋 No auto-messages configured.' },
- })
- })
-
- it('displays single welcome message', async () => {
- getMessagesByTypeMock.mockImplementation((guildId, type) => {
- if (type === 'welcome') {
- return Promise.resolve([
- {
- enabled: true,
- type: 'welcome',
- channelId: 'channel-123',
- message: 'Welcome {user}!',
- },
- ])
- }
- return Promise.resolve([])
- })
- const interaction = createInteraction()
-
- await handleAutoMessageList(interaction)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- color: 0x5865f2,
- title: '📋 Auto-Messages',
- description:
- expect.stringContaining('✅ **WELCOME**'),
- }),
- }),
- ],
- },
- })
- })
-
- it('displays multiple auto-messages with correct formatting', async () => {
- getMessagesByTypeMock.mockImplementation((guildId, type) => {
- if (type === 'welcome') {
- return Promise.resolve([
- {
- enabled: true,
- type: 'welcome',
- channelId: 'channel-123',
- message: 'Welcome to {server}!',
- },
- ])
- }
- return Promise.resolve([
- {
- enabled: false,
- type: 'leave',
- channelId: 'channel-456',
- message: 'Goodbye {user}!',
- },
- ])
- })
- const interaction = createInteraction()
-
- await handleAutoMessageList(interaction)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- description:
- expect.stringContaining('✅ **WELCOME**'),
- footer: expect.objectContaining({
- text: 'Total: 2 auto-messages',
- }),
- }),
- }),
- ],
- },
- })
- })
-
- it('truncates long messages in list preview', async () => {
- const longMessage = 'x'.repeat(100)
- getMessagesByTypeMock.mockImplementation((guildId, type) => {
- if (type === 'welcome') {
- return Promise.resolve([
- {
- enabled: true,
- type: 'welcome',
- channelId: 'channel-123',
- message: longMessage,
- },
- ])
- }
- return Promise.resolve([])
- })
- const interaction = createInteraction()
-
- await handleAutoMessageList(interaction)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- description: expect.stringContaining(
- longMessage.substring(0, 50) + '...',
- ),
- }),
- }),
- ],
- },
- })
- })
-
- it('displays messages with missing channel as "Not set"', async () => {
- getMessagesByTypeMock.mockImplementation((guildId, type) => {
- if (type === 'welcome') {
- return Promise.resolve([
- {
- enabled: true,
- type: 'welcome',
- channelId: null,
- message: 'Welcome!',
- },
- ])
- }
- return Promise.resolve([])
- })
- const interaction = createInteraction()
-
- await handleAutoMessageList(interaction)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- description:
- expect.stringContaining('Channel: Not set'),
- }),
- }),
- ],
- },
- })
- })
-
- it('displays messages with missing message text as "No message"', async () => {
- getMessagesByTypeMock.mockImplementation((guildId, type) => {
- if (type === 'leave') {
- return Promise.resolve([
- {
- enabled: false,
- type: 'leave',
- channelId: 'channel-789',
- message: null,
- },
- ])
- }
- return Promise.resolve([])
- })
- const interaction = createInteraction()
-
- await handleAutoMessageList(interaction)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- description: expect.stringContaining(
- 'Message: No message',
- ),
- }),
- }),
- ],
- },
- })
- })
-
- it('formats channel ID as mention in list', async () => {
- getMessagesByTypeMock.mockImplementation((guildId, type) => {
- if (type === 'welcome') {
- return Promise.resolve([
- {
- enabled: true,
- type: 'welcome',
- channelId: 'channel-999',
- message: 'Hi!',
- },
- ])
- }
- return Promise.resolve([])
- })
- const interaction = createInteraction()
-
- await handleAutoMessageList(interaction)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- embeds: [
- expect.objectContaining({
- data: expect.objectContaining({
- description:
- expect.stringContaining('<#channel-999>'),
- }),
- }),
- ],
- },
- })
- })
-})
diff --git a/packages/bot/src/functions/moderation/commands/case.spec.ts b/packages/bot/src/functions/moderation/commands/case.spec.ts
index 9ca8b0486..faa9daa25 100644
--- a/packages/bot/src/functions/moderation/commands/case.spec.ts
+++ b/packages/bot/src/functions/moderation/commands/case.spec.ts
@@ -67,103 +67,12 @@ describe('case command', () => {
})
describe('view subcommand', () => {
- test('calls handleCaseView with correct case number', async () => {
- const interaction = createInteraction({
- subcommand: 'view',
- caseNumber: 42,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseViewMock).toHaveBeenCalledWith(interaction, 42)
- })
-
- test('routes to view handler', async () => {
- const interaction = createInteraction({
- subcommand: 'view',
- caseNumber: 5,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseViewMock).toHaveBeenCalled()
- expect(handleCaseUpdateMock).not.toHaveBeenCalled()
- expect(handleCaseDeleteMock).not.toHaveBeenCalled()
- })
-
- test('handles case number 1', async () => {
- const interaction = createInteraction({
- subcommand: 'view',
- caseNumber: 1,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseViewMock).toHaveBeenCalledWith(interaction, 1)
- })
-
- test('handles large case numbers', async () => {
- const interaction = createInteraction({
- subcommand: 'view',
- caseNumber: 999999,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseViewMock).toHaveBeenCalledWith(interaction, 999999)
- })
})
describe('update subcommand', () => {
- test('calls handleCaseUpdate with correct case number', async () => {
- const interaction = createInteraction({
- subcommand: 'update',
- caseNumber: 42,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseUpdateMock).toHaveBeenCalledWith(interaction, 42)
- })
-
- test('routes to update handler', async () => {
- const interaction = createInteraction({
- subcommand: 'update',
- caseNumber: 7,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseUpdateMock).toHaveBeenCalled()
- expect(handleCaseViewMock).not.toHaveBeenCalled()
- expect(handleCaseDeleteMock).not.toHaveBeenCalled()
- })
})
describe('delete subcommand', () => {
- test('calls handleCaseDelete with correct case number', async () => {
- const interaction = createInteraction({
- subcommand: 'delete',
- caseNumber: 42,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseDeleteMock).toHaveBeenCalledWith(interaction, 42)
- })
-
- test('routes to delete handler', async () => {
- const interaction = createInteraction({
- subcommand: 'delete',
- caseNumber: 3,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseDeleteMock).toHaveBeenCalled()
- expect(handleCaseViewMock).not.toHaveBeenCalled()
- expect(handleCaseUpdateMock).not.toHaveBeenCalled()
- })
})
describe('error handling', () => {
@@ -298,31 +207,6 @@ describe('case command', () => {
})
describe('subcommand routing', () => {
- test('extracts case number correctly', async () => {
- const interaction = createInteraction({
- subcommand: 'view',
- caseNumber: 123,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(interaction.options.getInteger).toHaveBeenCalledWith(
- 'case_number',
- true,
- )
- })
-
- test('extracts subcommand correctly', async () => {
- const interaction = createInteraction({
- subcommand: 'view',
- caseNumber: 1,
- })
-
- await caseCommand.execute({ interaction } as any)
-
- expect(interaction.options.getSubcommand).toHaveBeenCalled()
- })
-
test('ignores unrecognized subcommands gracefully', async () => {
const interaction = createInteraction({ subcommand: 'unknown' })
@@ -335,43 +219,5 @@ describe('case command', () => {
})
describe('successful operations', () => {
- test('view handler completes successfully', async () => {
- const interaction = createInteraction({
- subcommand: 'view',
- caseNumber: 1,
- })
- handleCaseViewMock.mockResolvedValue(undefined)
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseViewMock).toHaveBeenCalled()
- expect(errorLogMock).not.toHaveBeenCalled()
- })
-
- test('update handler completes successfully', async () => {
- const interaction = createInteraction({
- subcommand: 'update',
- caseNumber: 1,
- })
- handleCaseUpdateMock.mockResolvedValue(undefined)
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseUpdateMock).toHaveBeenCalled()
- expect(errorLogMock).not.toHaveBeenCalled()
- })
-
- test('delete handler completes successfully', async () => {
- const interaction = createInteraction({
- subcommand: 'delete',
- caseNumber: 1,
- })
- handleCaseDeleteMock.mockResolvedValue(undefined)
-
- await caseCommand.execute({ interaction } as any)
-
- expect(handleCaseDeleteMock).toHaveBeenCalled()
- expect(errorLogMock).not.toHaveBeenCalled()
- })
})
})
diff --git a/packages/bot/src/functions/moderation/commands/unban.spec.ts b/packages/bot/src/functions/moderation/commands/unban.spec.ts
deleted file mode 100644
index de8f9a326..000000000
--- a/packages/bot/src/functions/moderation/commands/unban.spec.ts
+++ /dev/null
@@ -1,220 +0,0 @@
-import { describe, test, expect, jest, beforeEach } from '@jest/globals'
-import unbanCommand from './unban.js'
-
-const createCaseMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const infoLogMock = jest.fn()
-const errorLogMock = jest.fn()
-
-jest.mock('@lucky/shared/services', () => ({
- moderationService: {
- createCase: (...args: unknown[]) => createCaseMock(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- infoLog: (...args: unknown[]) => infoLogMock(...args),
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply.js', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-function createGuild(id = 'guild-123', name = 'Test Guild') {
- return {
- id,
- name,
- members: {
- unban: jest.fn().mockResolvedValue(undefined),
- },
- } as any
-}
-
-function createInteraction({
- guildId = 'guild-123',
- guild = null as any,
- userId = 'mod-123',
- userTag = 'Moderator#5678',
- channelId = 'channel-123',
- user = null as any,
- userId_option = 'user-123',
- reason = null as string | null,
-} = {}) {
- const interaction = {
- guild: guild || createGuild(guildId),
- guildId,
- channelId,
- user: user || { id: userId, tag: userTag },
- client: {
- users: {
- fetch: jest.fn().mockResolvedValue({
- id: userId_option,
- tag: 'TestUser#1234',
- }),
- },
- },
- options: {
- getString: jest.fn((name: string) => {
- if (name === 'user_id') return userId_option
- if (name === 'reason') return reason
- return null
- }),
- },
- }
-
- return interaction as any
-}
-
-describe('unban command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- createCaseMock.mockResolvedValue({
- caseNumber: 42,
- type: 'unban',
- })
- })
-
- test('rejects command outside of guild', async () => {
- const interaction = {
- guild: null,
- options: {
- getString: jest.fn(),
- },
- } as any
-
- await unbanCommand.execute({ interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- content: '❌ This command can only be used in a server.',
- },
- })
- })
-
- test('unbans user with default reason', async () => {
- const interaction = createInteraction()
-
- await unbanCommand.execute({ interaction } as any)
-
- expect(interaction.guild.members.unban).toHaveBeenCalledWith(
- 'user-123',
- 'No reason provided',
- )
- expect(createCaseMock).toHaveBeenCalledWith({
- guildId: 'guild-123',
- type: 'unban',
- userId: 'user-123',
- username: 'TestUser#1234',
- moderatorId: 'mod-123',
- moderatorName: 'Moderator#5678',
- reason: 'No reason provided',
- channelId: 'channel-123',
- })
- })
-
- test('unbans user with custom reason', async () => {
- const interaction = createInteraction({ reason: 'Appeal approved' })
-
- await unbanCommand.execute({ interaction } as any)
-
- expect(createCaseMock).toHaveBeenCalledWith(
- expect.objectContaining({
- reason: 'Appeal approved',
- }),
- )
- })
-
- test('sends embed reply with case number', async () => {
- const interaction = createInteraction()
-
- await unbanCommand.execute({ interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: expect.objectContaining({
- embeds: expect.arrayContaining([
- expect.objectContaining({
- data: expect.objectContaining({
- title: expect.stringContaining('Case #42'),
- }),
- }),
- ]),
- }),
- })
- })
-
- test('fetches user info from API when user not cached', async () => {
- const interaction = createInteraction()
-
- await unbanCommand.execute({ interaction } as any)
-
- expect(interaction.client.users.fetch).toHaveBeenCalledWith('user-123')
- })
-
- test('uses user ID as username when user not found in API', async () => {
- const interaction = createInteraction()
- interaction.client.users.fetch.mockRejectedValue(
- new Error('User not found'),
- )
-
- await unbanCommand.execute({ interaction } as any)
-
- expect(createCaseMock).toHaveBeenCalledWith(
- expect.objectContaining({
- username: 'user-123',
- }),
- )
- })
-
- test('logs unban action to info log', async () => {
- const interaction = createInteraction()
-
- await unbanCommand.execute({ interaction } as any)
-
- expect(infoLogMock).toHaveBeenCalledWith({
- message: expect.stringContaining('unbanned'),
- })
- })
-
- test('handles guild unban error', async () => {
- const interaction = createInteraction()
- interaction.guild.members.unban.mockRejectedValue(
- new Error('unban failed'),
- )
-
- await unbanCommand.execute({ interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Failed to unban user',
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- content:
- '❌ Failed to unban user. User may not be banned or ID is invalid.',
- },
- })
- })
-
- test('handles moderation service error after unban', async () => {
- const interaction = createInteraction()
- createCaseMock.mockRejectedValue(new Error('db error'))
-
- await unbanCommand.execute({ interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Failed to unban user',
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- interaction,
- }),
- )
- })
-})
diff --git a/packages/bot/src/functions/moderation/commands/unmute.spec.ts b/packages/bot/src/functions/moderation/commands/unmute.spec.ts
deleted file mode 100644
index 9d29d0876..000000000
--- a/packages/bot/src/functions/moderation/commands/unmute.spec.ts
+++ /dev/null
@@ -1,292 +0,0 @@
-import { describe, test, expect, jest, beforeEach } from '@jest/globals'
-import unmuteCommand from './unmute.js'
-
-const createCaseMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const infoLogMock = jest.fn()
-const errorLogMock = jest.fn()
-
-jest.mock('@lucky/shared/services', () => ({
- moderationService: {
- createCase: (...args: unknown[]) => createCaseMock(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- infoLog: (...args: unknown[]) => infoLogMock(...args),
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply.js', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-function createUser(id = 'user-123', tag = 'TestUser#1234') {
- return {
- id,
- tag,
- send: jest.fn(),
- } as any
-}
-
-function createMember(userId = 'user-123') {
- return {
- timeout: jest.fn().mockResolvedValue(undefined),
- } as any
-}
-
-function createGuild(id = 'guild-123', name = 'Test Guild') {
- return {
- id,
- name,
- members: {
- fetch: jest.fn(async () => createMember()),
- },
- } as any
-}
-
-function createInteraction({
- guildId = 'guild-123',
- guild = null as any,
- userId = 'mod-123',
- userTag = 'Moderator#5678',
- channelId = 'channel-123',
- user = null as any,
- reason = null as string | null,
-} = {}) {
- const interaction = {
- guild: guild || createGuild(guildId),
- guildId,
- channelId,
- user: user || { id: userId, tag: userTag },
- options: {
- getUser: jest.fn((name: string, required?: boolean) => {
- if (name === 'user') return createUser()
- return null
- }),
- getString: jest.fn((name: string) => {
- if (name === 'reason') return reason
- return null
- }),
- },
- }
-
- return interaction as any
-}
-
-describe('unmute command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- createCaseMock.mockResolvedValue({
- caseNumber: 42,
- type: 'unmute',
- })
- })
-
- test('rejects command outside of guild', async () => {
- const interaction = {
- guild: null,
- options: {
- getUser: jest.fn(),
- getString: jest.fn(),
- },
- } as any
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- content: '❌ This command can only be used in a server.',
- },
- })
- })
-
- test('rejects when user not found in guild', async () => {
- const interaction = createInteraction()
- const user = createUser()
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockResolvedValue(null)
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: { content: '❌ User not found in this server.' },
- })
- })
-
- test('unmutes user with default reason', async () => {
- const interaction = createInteraction()
- const user = createUser()
- const member = createMember(user.id)
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockResolvedValue(member)
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(member.timeout).toHaveBeenCalledWith(null, 'No reason provided')
- expect(createCaseMock).toHaveBeenCalledWith({
- guildId: 'guild-123',
- type: 'unmute',
- userId: 'user-123',
- username: 'TestUser#1234',
- moderatorId: 'mod-123',
- moderatorName: 'Moderator#5678',
- reason: 'No reason provided',
- channelId: 'channel-123',
- })
- })
-
- test('unmutes user with custom reason', async () => {
- const interaction = createInteraction({ reason: 'Appeal approved' })
- const user = createUser()
- const member = createMember(user.id)
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockResolvedValue(member)
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(member.timeout).toHaveBeenCalledWith(null, 'Appeal approved')
- expect(createCaseMock).toHaveBeenCalledWith(
- expect.objectContaining({
- reason: 'Appeal approved',
- }),
- )
- })
-
- test('sends embed reply with case number', async () => {
- const interaction = createInteraction()
- const user = createUser()
- const member = createMember(user.id)
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockResolvedValue(member)
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: expect.objectContaining({
- embeds: expect.arrayContaining([
- expect.objectContaining({
- data: expect.objectContaining({
- title: expect.stringContaining('Case #42'),
- }),
- }),
- ]),
- }),
- })
- })
-
- test('sends DM to user when unmuted', async () => {
- const interaction = createInteraction()
- const user = createUser()
- const sendMock = jest.fn().mockResolvedValue(undefined)
- user.send = sendMock
- const member = createMember(user.id)
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockResolvedValue(member)
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(sendMock).toHaveBeenCalledWith({
- embeds: expect.arrayContaining([
- expect.objectContaining({
- data: expect.objectContaining({
- title: expect.stringContaining('unmuted'),
- }),
- }),
- ]),
- })
- })
-
- test('handles DM failure gracefully', async () => {
- const interaction = createInteraction()
- const user = createUser()
- user.send = jest.fn().mockRejectedValue(new Error('DM failed'))
- const member = createMember(user.id)
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockResolvedValue(member)
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Failed to send DM'),
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- test('logs unmute action to info log', async () => {
- const interaction = createInteraction()
- const user = createUser()
- const member = createMember(user.id)
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockResolvedValue(member)
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(infoLogMock).toHaveBeenCalledWith({
- message: expect.stringContaining('unmuted'),
- })
- })
-
- test('handles member fetch error', async () => {
- const interaction = createInteraction()
- const user = createUser()
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockRejectedValue(
- new Error('fetch failed'),
- )
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Failed to unmute user',
- }),
- )
- })
-
- test('handles timeout error', async () => {
- const interaction = createInteraction()
- const user = createUser()
- const member = createMember(user.id)
- member.timeout.mockRejectedValue(new Error('timeout failed'))
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockResolvedValue(member)
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Failed to unmute user',
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- content:
- '❌ Failed to unmute user. Please check permissions and try again.',
- },
- })
- })
-
- test('handles moderation service error', async () => {
- const interaction = createInteraction()
- const user = createUser()
- const member = createMember(user.id)
- interaction.options.getUser.mockReturnValue(user)
- interaction.guild.members.fetch.mockResolvedValue(member)
- createCaseMock.mockRejectedValue(new Error('db error'))
-
- await unmuteCommand.execute({ interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Failed to unmute user',
- }),
- )
- })
-})
diff --git a/packages/bot/src/functions/moderation/commands/warn.spec.ts b/packages/bot/src/functions/moderation/commands/warn.spec.ts
deleted file mode 100644
index 61f420db9..000000000
--- a/packages/bot/src/functions/moderation/commands/warn.spec.ts
+++ /dev/null
@@ -1,238 +0,0 @@
-import { describe, test, expect, jest, beforeEach } from '@jest/globals'
-import warnCommand from './warn.js'
-
-const createCaseMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const infoLogMock = jest.fn()
-const errorLogMock = jest.fn()
-
-jest.mock('@lucky/shared/services', () => ({
- moderationService: {
- createCase: (...args: unknown[]) => createCaseMock(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- infoLog: (...args: unknown[]) => infoLogMock(...args),
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply.js', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-function createUser(id = 'user-123', tag = 'TestUser#1234') {
- return {
- id,
- tag,
- send: jest.fn(),
- } as any
-}
-
-function createGuild(id = 'guild-123', name = 'Test Guild') {
- return {
- id,
- name,
- } as any
-}
-
-function createInteraction({
- guildId = 'guild-123',
- guild = null as any,
- userId = 'mod-123',
- userTag = 'Moderator#5678',
- channelId = 'channel-123',
- user = null as any,
- reason = null as string | null,
- silent = null as boolean | null,
-} = {}) {
- const interaction = {
- guild: guild || createGuild(guildId),
- guildId,
- channelId,
- user: user || { id: userId, tag: userTag },
- options: {
- getUser: jest.fn((name: string, required?: boolean) => {
- if (name === 'user') return createUser()
- return null
- }),
- getString: jest.fn((name: string) => {
- if (name === 'reason') return reason
- return null
- }),
- getBoolean: jest.fn((name: string) => {
- if (name === 'silent') return silent
- return null
- }),
- },
- }
-
- return interaction as any
-}
-
-describe('warn command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- createCaseMock.mockResolvedValue({
- caseNumber: 42,
- type: 'warn',
- })
- })
-
- test('rejects command outside of guild', async () => {
- const interaction = {
- guild: null,
- options: {
- getUser: jest.fn(),
- getString: jest.fn(),
- getBoolean: jest.fn(),
- },
- } as any
-
- await warnCommand.execute({ interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- content: '❌ This command can only be used in a server.',
- },
- })
- })
-
- test('warns user with default reason', async () => {
- const interaction = createInteraction()
- const user = createUser()
- interaction.options.getUser.mockReturnValue(user)
-
- await warnCommand.execute({ interaction } as any)
-
- expect(createCaseMock).toHaveBeenCalledWith({
- guildId: 'guild-123',
- type: 'warn',
- userId: 'user-123',
- username: 'TestUser#1234',
- moderatorId: 'mod-123',
- moderatorName: 'Moderator#5678',
- reason: 'No reason provided',
- channelId: 'channel-123',
- })
- expect(infoLogMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- test('warns user with custom reason', async () => {
- const interaction = createInteraction({ reason: 'Spamming in chat' })
- const user = createUser()
- interaction.options.getUser.mockReturnValue(user)
-
- await warnCommand.execute({ interaction } as any)
-
- expect(createCaseMock).toHaveBeenCalledWith(
- expect.objectContaining({
- reason: 'Spamming in chat',
- }),
- )
- })
-
- test('sends embed reply with case number', async () => {
- const interaction = createInteraction()
- const user = createUser()
- interaction.options.getUser.mockReturnValue(user)
-
- await warnCommand.execute({ interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: expect.objectContaining({
- embeds: expect.arrayContaining([
- expect.objectContaining({
- data: expect.objectContaining({
- title: expect.stringContaining('Case #42'),
- }),
- }),
- ]),
- }),
- })
- })
-
- test('sends DM to user when not silent', async () => {
- const interaction = createInteraction({ silent: false })
- const user = createUser()
- const sendMock = jest.fn().mockResolvedValue(undefined)
- user.send = sendMock
- interaction.options.getUser.mockReturnValue(user)
-
- await warnCommand.execute({ interaction } as any)
-
- expect(sendMock).toHaveBeenCalledWith({
- embeds: expect.arrayContaining([
- expect.objectContaining({
- data: expect.objectContaining({
- title: expect.stringContaining('warned'),
- }),
- }),
- ]),
- })
- })
-
- test('does not send DM when silent flag is true', async () => {
- const interaction = createInteraction({ silent: true })
- const user = createUser()
- const sendMock = jest.fn().mockResolvedValue(undefined)
- user.send = sendMock
- interaction.options.getUser.mockReturnValue(user)
-
- await warnCommand.execute({ interaction } as any)
-
- expect(sendMock).not.toHaveBeenCalled()
- })
-
- test('handles DM failure gracefully', async () => {
- const interaction = createInteraction({ silent: false })
- const user = createUser()
- user.send = jest.fn().mockRejectedValue(new Error('DM failed'))
- interaction.options.getUser.mockReturnValue(user)
-
- await warnCommand.execute({ interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Failed to send DM'),
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- test('handles moderation service error', async () => {
- const interaction = createInteraction()
- const user = createUser()
- interaction.options.getUser.mockReturnValue(user)
- createCaseMock.mockRejectedValue(new Error('db error'))
-
- await warnCommand.execute({ interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Failed to issue warning',
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith({
- interaction,
- content: {
- content: '❌ Failed to issue warning. Please try again.',
- },
- })
- })
-
- test('logs warning action to info log', async () => {
- const interaction = createInteraction()
- const user = createUser()
- interaction.options.getUser.mockReturnValue(user)
-
- await warnCommand.execute({ interaction } as any)
-
- expect(infoLogMock).toHaveBeenCalledWith({
- message: expect.stringContaining('Warning issued'),
- })
- })
-})
diff --git a/packages/bot/src/functions/music/commands/album.spec.ts b/packages/bot/src/functions/music/commands/album.spec.ts
deleted file mode 100644
index 11fdbf218..000000000
--- a/packages/bot/src/functions/music/commands/album.spec.ts
+++ /dev/null
@@ -1,318 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-
-const requireVoiceChannelMock =
- jest.fn<(interaction: unknown) => Promise>()
-const requireDJRoleMock = jest.fn()
-const errorLogMock = jest.fn()
-const resolveGuildQueueMock =
- jest.fn<(client: unknown, guildId: string) => { queue: unknown }>()
-const moveUserTrackToPriorityMock = jest.fn()
-const interactionReplyMock = jest.fn<(payload: unknown) => Promise>()
-const createErrorEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'error',
- title,
- message,
-}))
-const createSuccessEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'success',
- title,
- message,
-}))
-const createWarningEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'warning',
- title,
- message,
-}))
-const featureToggleIsEnabledMock = jest.fn<() => Promise>()
-
-jest.mock('discord-player', () => ({
- QueryType: {
- AUTO: 'auto',
- SPOTIFY_SEARCH: 'spotifySearch',
- SPOTIFY_SONG: 'spotifySong',
- },
-}))
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireVoiceChannel: (interaction: unknown) =>
- requireVoiceChannelMock(interaction),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (client: unknown, guildId: string) =>
- resolveGuildQueueMock(client, guildId),
-}))
-
-jest.mock('../../../utils/music/queueManipulation', () => ({
- moveUserTrackToPriority: (queue: unknown, track: unknown) =>
- moveUserTrackToPriorityMock(queue, track),
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: (payload: unknown) => errorLogMock(payload),
-}))
-
-jest.mock('@lucky/shared/utils/general/errorSanitizer', () => ({
- createUserFriendlyError: () => 'User friendly error',
-}))
-
-jest.mock('@lucky/shared/config', () => ({
- ENVIRONMENT_CONFIG: { PLAYER: { CONNECTION_TIMEOUT: 15000 } },
-}))
-
-jest.mock('@lucky/shared/services', () => ({
- featureToggleService: {
- isEnabled: (...args: unknown[]) => featureToggleIsEnabledMock(...args),
- },
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createErrorEmbed: (title: string, message: string) =>
- createErrorEmbedMock(title, message),
- createSuccessEmbed: (title: string, message: string) =>
- createSuccessEmbedMock(title, message),
- createWarningEmbed: (title: string, message: string) =>
- createWarningEmbedMock(title, message),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (payload: unknown) => interactionReplyMock(payload),
-}))
-
-jest.mock('./play/queryUtils', () => ({
- isUnknownInteractionError: () => false,
-}))
-
-import albumCommand from './album'
-
-function createInteraction(
- guildId: string | null,
- query = 'OK Computer',
- artist: string | null = null,
-) {
- return {
- guildId,
- user: { id: 'user-1' },
- channel: { id: 'channel-1' },
- member: { voice: { channel: { id: 'voice-1' } } },
- options: {
- getString: jest.fn((name: string) =>
- name === 'query' ? query : artist,
- ),
- },
- reply: jest.fn(),
- deferReply: jest.fn(),
- editReply: jest.fn(),
- } as any
-}
-
-function createClient(
- searchResult: unknown,
- playResult: unknown = { track: { url: 'url-1' } },
-) {
- return {
- player: {
- search: jest.fn(async () => searchResult),
- play: jest.fn(async () => playResult),
- },
- user: { id: 'bot-1' },
- } as any
-}
-
-describe('album command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireVoiceChannelMock.mockResolvedValue(true)
- requireDJRoleMock.mockResolvedValue(true)
- createErrorEmbedMock.mockImplementation(
- (title: string, message: string) => ({
- type: 'error',
- title,
- message,
- }),
- )
- createSuccessEmbedMock.mockImplementation(
- (title: string, message: string) => ({
- type: 'success',
- title,
- message,
- }),
- )
- createWarningEmbedMock.mockImplementation(
- (title: string, message: string) => ({
- type: 'warning',
- title,
- message,
- }),
- )
- featureToggleIsEnabledMock.mockResolvedValue(true)
- })
-
- it('replies with warning when ALBUM_COMMAND feature is disabled', async () => {
- featureToggleIsEnabledMock.mockResolvedValue(false)
- const interaction = createInteraction('guild-1')
- await albumCommand.execute({
- client: createClient(null),
- interaction,
- } as any)
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: expect.arrayContaining([
- expect.objectContaining({ type: 'warning' }),
- ]),
- ephemeral: true,
- }),
- }),
- )
- expect(interaction.deferReply).not.toHaveBeenCalled()
- })
-
- it('replies with error when not in a guild', async () => {
- const interaction = createInteraction(null)
- await albumCommand.execute({
- client: createClient(null),
- interaction,
- } as any)
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({ content: expect.objectContaining({ ephemeral: true }) }),
- )
- })
-
- it('returns early when voice channel check fails', async () => {
- requireVoiceChannelMock.mockResolvedValue(false)
- const interaction = createInteraction('guild-1')
- await albumCommand.execute({
- client: createClient(null),
- interaction,
- } as any)
- expect(interaction.deferReply).not.toHaveBeenCalled()
- })
-
- it('returns early when DJ role check fails', async () => {
- requireDJRoleMock.mockResolvedValue(false)
- const interaction = createInteraction('guild-1')
- await albumCommand.execute({
- client: createClient(null),
- interaction,
- } as any)
- expect(interaction.deferReply).not.toHaveBeenCalled()
- })
-
- it('replies with error when no tracks found', async () => {
- const interaction = createInteraction('guild-1')
- const client = createClient({ tracks: [] })
-
- await albumCommand.execute({ client, interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: expect.arrayContaining([
- expect.objectContaining({ type: 'error' }),
- ]),
- }),
- }),
- )
- })
-
- it('queues all playlist tracks and replies with success', async () => {
- const tracks = [
- { url: 'url-1', requestedBy: null },
- { url: 'url-2', requestedBy: null },
- { url: 'url-3', requestedBy: null },
- ]
- const interaction = createInteraction('guild-1')
- const queue = { addTrack: jest.fn() }
- const client = createClient(
- { tracks, playlist: { title: 'OK Computer' } },
- { track: tracks[0] },
- )
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await albumCommand.execute({ client, interaction } as any)
-
- expect(queue.addTrack).toHaveBeenCalledTimes(2)
- expect(moveUserTrackToPriorityMock).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- expect.stringContaining('OK Computer'),
- expect.stringContaining('3'),
- )
- })
-
- it('uses AUTO search engine for Spotify album URLs', async () => {
- const spotifyUrl = 'https://open.spotify.com/album/abc123'
- const tracks = [{ url: spotifyUrl, requestedBy: null }]
- const interaction = createInteraction('guild-1', spotifyUrl)
- const queue = { addTrack: jest.fn() }
- const client = createClient(
- { tracks, playlist: { title: 'Album' } },
- { track: tracks[0] },
- )
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await albumCommand.execute({ client, interaction } as any)
-
- expect(client.player.search).toHaveBeenCalledWith(
- spotifyUrl,
- expect.objectContaining({ searchEngine: 'auto' }),
- )
- })
-
- it('replies with error when queue cannot be created', async () => {
- const tracks = [{ url: 'url-1', requestedBy: null }]
- const interaction = createInteraction('guild-1')
- const client = createClient(
- {
- tracks,
- playlist: {
- title: 'Test Album',
- url: 'https://open.spotify.com/album/123',
- },
- },
- { track: tracks[0] },
- )
- resolveGuildQueueMock.mockReturnValue({ queue: null })
-
- await albumCommand.execute({ client, interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({ ephemeral: true }),
- }),
- )
- })
-
- it('logs and replies with error on unexpected exception', async () => {
- const interaction = createInteraction('guild-1')
- const client = createClient(null)
- client.player.search.mockRejectedValue(new Error('Spotify timeout'))
-
- await albumCommand.execute({ client, interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- })
- it('replies with error when search returns tracks but no playlist', async () => {
- const tracks = [
- { url: 'url-1', requestedBy: null },
- { url: 'url-2', requestedBy: null },
- ]
- const interaction = createInteraction('guild-1', 'Rumours')
- const client = createClient({ playlist: null, tracks })
- resolveGuildQueueMock.mockReturnValue({ queue: null })
-
- await albumCommand.execute({ client, interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: expect.arrayContaining([
- expect.objectContaining({ type: 'error' }),
- ]),
- }),
- }),
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/artist.spec.ts b/packages/bot/src/functions/music/commands/artist.spec.ts
deleted file mode 100644
index 9ef1dd127..000000000
--- a/packages/bot/src/functions/music/commands/artist.spec.ts
+++ /dev/null
@@ -1,272 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-
-const requireVoiceChannelMock =
- jest.fn<(interaction: unknown) => Promise>()
-const requireDJRoleMock = jest.fn()
-const errorLogMock = jest.fn()
-const resolveGuildQueueMock =
- jest.fn<(client: unknown, guildId: string) => { queue: unknown }>()
-const moveUserTrackToPriorityMock = jest.fn()
-const interactionReplyMock = jest.fn<(payload: unknown) => Promise>()
-const createErrorEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'error',
- title,
- message,
-}))
-const createSuccessEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'success',
- title,
- message,
-}))
-const createWarningEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'warning',
- title,
- message,
-}))
-const featureToggleIsEnabledMock = jest.fn<() => Promise>()
-
-jest.mock('discord-player', () => ({
- QueryType: { SPOTIFY_SEARCH: 'spotifySearch' },
-}))
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireVoiceChannel: (interaction: unknown) =>
- requireVoiceChannelMock(interaction),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (client: unknown, guildId: string) =>
- resolveGuildQueueMock(client, guildId),
-}))
-
-jest.mock('../../../utils/music/queueManipulation', () => ({
- moveUserTrackToPriority: (queue: unknown, track: unknown) =>
- moveUserTrackToPriorityMock(queue, track),
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: (payload: unknown) => errorLogMock(payload),
-}))
-
-jest.mock('@lucky/shared/utils/general/errorSanitizer', () => ({
- createUserFriendlyError: () => 'User friendly error',
-}))
-
-jest.mock('@lucky/shared/config', () => ({
- ENVIRONMENT_CONFIG: { PLAYER: { CONNECTION_TIMEOUT: 15000 } },
-}))
-
-jest.mock('@lucky/shared/services', () => ({
- featureToggleService: {
- isEnabled: (...args: unknown[]) => featureToggleIsEnabledMock(...args),
- },
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createErrorEmbed: (title: string, message: string) =>
- createErrorEmbedMock(title, message),
- createSuccessEmbed: (title: string, message: string) =>
- createSuccessEmbedMock(title, message),
- createWarningEmbed: (title: string, message: string) =>
- createWarningEmbedMock(title, message),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (payload: unknown) => interactionReplyMock(payload),
-}))
-
-jest.mock('./play/queryUtils', () => ({
- isUnknownInteractionError: () => false,
-}))
-
-import artistCommand from './artist'
-
-function createInteraction(
- guildId: string | null,
- artistName = 'Radiohead',
- limit: number | null = null,
-) {
- return {
- guildId,
- user: { id: 'user-1' },
- channel: { id: 'channel-1' },
- member: { voice: { channel: { id: 'voice-1' } } },
- options: {
- getString: jest.fn(() => artistName),
- getInteger: jest.fn(() => limit),
- },
- reply: jest.fn(),
- deferReply: jest.fn(),
- editReply: jest.fn(),
- } as any
-}
-
-function createClient(
- searchResult: unknown,
- playResult: unknown = { track: { url: 'url-1' } },
-) {
- return {
- player: {
- search: jest.fn(async () => searchResult),
- play: jest.fn(async () => playResult),
- },
- user: { id: 'bot-1' },
- } as any
-}
-
-describe('artist command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireVoiceChannelMock.mockResolvedValue(true)
- requireDJRoleMock.mockResolvedValue(true)
- createErrorEmbedMock.mockImplementation(
- (title: string, message: string) => ({
- type: 'error',
- title,
- message,
- }),
- )
- createSuccessEmbedMock.mockImplementation(
- (title: string, message: string) => ({
- type: 'success',
- title,
- message,
- }),
- )
- createWarningEmbedMock.mockImplementation(
- (title: string, message: string) => ({
- type: 'warning',
- title,
- message,
- }),
- )
- featureToggleIsEnabledMock.mockResolvedValue(true)
- })
-
- it('replies with warning when ARTIST_COMMAND feature is disabled', async () => {
- featureToggleIsEnabledMock.mockResolvedValue(false)
- const interaction = createInteraction('guild-1')
- await artistCommand.execute({
- client: createClient(null),
- interaction,
- } as any)
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: expect.arrayContaining([
- expect.objectContaining({ type: 'warning' }),
- ]),
- ephemeral: true,
- }),
- }),
- )
- expect(interaction.deferReply).not.toHaveBeenCalled()
- })
-
- it('replies with error when not in a guild', async () => {
- const interaction = createInteraction(null)
- await artistCommand.execute({
- client: createClient(null),
- interaction,
- } as any)
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({ content: expect.objectContaining({ ephemeral: true }) }),
- )
- })
-
- it('returns early when voice channel check fails', async () => {
- requireVoiceChannelMock.mockResolvedValue(false)
- const interaction = createInteraction('guild-1')
- await artistCommand.execute({
- client: createClient(null),
- interaction,
- } as any)
- expect(interaction.deferReply).not.toHaveBeenCalled()
- })
-
- it('returns early when DJ role check fails', async () => {
- requireDJRoleMock.mockResolvedValue(false)
- const interaction = createInteraction('guild-1')
- await artistCommand.execute({
- client: createClient(null),
- interaction,
- } as any)
- expect(interaction.deferReply).not.toHaveBeenCalled()
- })
-
- it('replies with error when no tracks found', async () => {
- const interaction = createInteraction('guild-1')
- const client = createClient({ tracks: [] })
- resolveGuildQueueMock.mockReturnValue({ queue: null })
-
- await artistCommand.execute({ client, interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: expect.arrayContaining([
- expect.objectContaining({ type: 'error' }),
- ]),
- }),
- }),
- )
- })
-
- it('queues tracks and replies with success on valid artist search', async () => {
- const tracks = [
- {
- url: 'url-1',
- author: 'Radiohead',
- title: 'Track 1',
- requestedBy: null,
- },
- {
- url: 'url-2',
- author: 'Radiohead',
- title: 'Track 2',
- requestedBy: null,
- },
- ]
- const interaction = createInteraction('guild-1')
- const queue = { addTrack: jest.fn(), tracks: { size: 0 } }
- const client = createClient({ tracks }, { track: tracks[0] })
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await artistCommand.execute({ client, interaction } as any)
-
- expect(queue.addTrack).toHaveBeenCalledTimes(1)
- expect(moveUserTrackToPriorityMock).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalled()
- })
-
- it('replies with error when queue cannot be created', async () => {
- const tracks = [{ url: 'url-1', requestedBy: null }]
- const interaction = createInteraction('guild-1')
- const client = createClient({ tracks }, { track: tracks[0] })
- resolveGuildQueueMock.mockReturnValue({ queue: null })
-
- await artistCommand.execute({ client, interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: expect.arrayContaining([
- expect.objectContaining({ type: 'error' }),
- ]),
- }),
- }),
- )
- })
-
- it('logs and replies with error on unexpected exception', async () => {
- const interaction = createInteraction('guild-1')
- const client = createClient(null)
- client.player.search.mockRejectedValue(new Error('Network error'))
-
- await artistCommand.execute({ client, interaction } as any)
-
- expect(errorLogMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-})
diff --git a/packages/bot/src/functions/music/commands/autoplay.spec.ts b/packages/bot/src/functions/music/commands/autoplay.spec.ts
index 0f259cf88..f290bec7d 100644
--- a/packages/bot/src/functions/music/commands/autoplay.spec.ts
+++ b/packages/bot/src/functions/music/commands/autoplay.spec.ts
@@ -154,74 +154,7 @@ describe('autoplay command', () => {
})
})
- describe('skip subcommand', () => {
- it('should skip first autoplay track and replenish', async () => {
- const interaction = createInteraction('skip')
- const queue = createQueue()
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue })
- replenishQueueMock.mockResolvedValue(undefined)
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(interaction.deferReply).toHaveBeenCalled()
- expect(queue.removeTrack).toHaveBeenCalledWith(0)
- expect(replenishQueueMock).toHaveBeenCalledWith(queue)
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should handle empty queue gracefully', async () => {
- const interaction = createInteraction('skip')
- const queue = createQueue()
- queue.tracks.size = 0
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Queue Empty',
- expect.anything(),
- )
- })
- })
-
- describe('clear subcommand', () => {
- it('should remove all autoplay tracks and replenish', async () => {
- const interaction = createInteraction('clear')
- const queue = createQueue()
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue })
- replenishQueueMock.mockResolvedValue(undefined)
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(queue.removeTrack).toHaveBeenCalledTimes(2)
- expect(replenishQueueMock).toHaveBeenCalledWith(queue)
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('should handle no autoplay tracks gracefully', async () => {
- const interaction = createInteraction('clear')
- const queue = createQueue()
- queue.tracks.at = jest.fn(() => ({
- metadata: { isAutoplay: false },
- }))
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await autoplayCommand.execute({ client, interaction } as any)
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'No Autoplay Tracks',
- expect.anything(),
- )
- })
- })
describe('status subcommand', () => {
it('should show autoplay status', async () => {
@@ -239,27 +172,6 @@ describe('autoplay command', () => {
expect(interactionReplyMock).toHaveBeenCalled()
})
- it('should show blend info when multiple vc members have last.fm', async () => {
- getLastFmLinkMock.mockResolvedValue({ lastFmUsername: 'user' })
- const interaction = createInteraction('status')
- const queue = createQueue({ vcMemberIds: ['user-1', 'user-2'] })
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createEmbedMock).toHaveBeenCalledWith(
- expect.objectContaining({
- fields: expect.arrayContaining([
- expect.objectContaining({
- name: '🎭 Blend',
- value: 'Mixing taste for 2 users',
- }),
- ]),
- }),
- )
- })
it('should not show blend when only one member has last.fm', async () => {
getLastFmLinkMock
@@ -283,36 +195,6 @@ describe('autoplay command', () => {
})
})
- describe('error handling', () => {
- it('should handle unknown interaction error gracefully', async () => {
- const interaction = createInteraction('skip')
- const error = { code: 10062 }
- interaction.deferReply = jest.fn(async () => {
- throw error
- })
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue: null })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('should reply with error when no queue exists', async () => {
- const interaction = createInteraction('skip')
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue: null })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'No Active Queue',
- expect.anything(),
- )
- })
- })
describe('analytics subcommand', () => {
it('should show autoplay analytics with data', async () => {
@@ -361,471 +243,10 @@ describe('autoplay command', () => {
expect(interactionReplyMock).toHaveBeenCalled()
})
- it('should handle service errors gracefully', async () => {
- const interaction = createInteraction('analytics')
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- trackHistoryServiceMock.mockRejectedValue(
- new Error('Service error'),
- )
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- expect.anything(),
- )
- expect(interactionReplyMock).toHaveBeenCalled()
- })
- })
-
- describe('mode subcommand', () => {
- it('should show current mode when no mode argument provided', async () => {
- const interaction = createInteraction('mode')
- interaction.options.getString = jest.fn().mockReturnValue(null)
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({
- autoplayMode: 'discover',
- })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(getGuildSettingsMock).toHaveBeenCalledWith('guild-1')
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(createEmbedMock).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('discover'),
- }),
- )
- })
-
- it('should show default mode when settings return null', async () => {
- const interaction = createInteraction('mode')
- interaction.options.getString = jest.fn().mockReturnValue(null)
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({})
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(createEmbedMock).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('similar'),
- }),
- )
- })
-
- it('should update mode when mode argument provided', async () => {
- const interaction = createInteraction('mode')
- interaction.options.getString = jest.fn().mockReturnValue('popular')
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- updateGuildSettingsMock.mockResolvedValue(true)
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(updateGuildSettingsMock).toHaveBeenCalledWith('guild-1', {
- autoplayMode: 'popular',
- })
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(createEmbedMock).toHaveBeenCalledWith(
- expect.objectContaining({
- title: expect.stringContaining('mode updated'),
- }),
- )
- })
-
- it('should show error when mode update fails', async () => {
- const interaction = createInteraction('mode')
- interaction.options.getString = jest
- .fn()
- .mockReturnValue('discover')
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- updateGuildSettingsMock.mockResolvedValue(false)
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- 'Failed to update autoplay mode.',
- )
- expect(interactionReplyMock).toHaveBeenCalled()
- })
})
- describe('skip subcommand edge cases', () => {
- it('should show error when no autoplay tracks found in queue', async () => {
- const interaction = createInteraction('skip')
- const queue = createQueue()
- queue.tracks.at = jest.fn(() => ({
- metadata: { isAutoplay: false },
- title: 'Manual Track',
- }))
- queue.tracks.size = 1
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'No Autoplay Tracks',
- 'No autoplay tracks found in queue.',
- )
- expect(queue.removeTrack).not.toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- })
- })
- describe('genre subcommand group', () => {
- function createGenreInteraction(subcommand: string, tag?: string) {
- const interaction = {
- guildId: 'guild-1',
- deferred: false,
- replied: false,
- user: { id: 'user-1' },
- deferReply: jest.fn(async () => {
- interaction.deferred = true
- }),
- options: {
- getSubcommand: jest.fn(() => subcommand),
- getSubcommandGroup: jest.fn(() => 'genre'),
- getString: jest.fn(() => tag ?? null),
- },
- }
- return interaction as any
- }
-
- it('genre add — adds new genre successfully', async () => {
- const interaction = createGenreInteraction('add', 'rock')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({ autoplayGenres: [] })
- updateGuildSettingsMock.mockResolvedValue(true)
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(updateGuildSettingsMock).toHaveBeenCalledWith('guild-1', {
- autoplayGenres: ['rock'],
- })
- expect(createEmbedMock).toHaveBeenCalled()
- })
-
- it('genre add — rejects when limit of 5 reached', async () => {
- const interaction = createGenreInteraction('add', 'jazz')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({
- autoplayGenres: ['rock', 'pop', 'indie', 'metal', 'electronic'],
- })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Limit Reached',
- expect.any(String),
- )
- })
-
- it('genre add — rejects duplicate genre', async () => {
- const interaction = createGenreInteraction('add', 'rock')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({ autoplayGenres: ['rock'] })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Already Added',
- expect.any(String),
- )
- })
-
- it('genre remove — removes existing genre', async () => {
- const interaction = createGenreInteraction('remove', 'rock')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({
- autoplayGenres: ['rock', 'pop'],
- })
- updateGuildSettingsMock.mockResolvedValue(true)
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(updateGuildSettingsMock).toHaveBeenCalledWith('guild-1', {
- autoplayGenres: ['pop'],
- })
- expect(createEmbedMock).toHaveBeenCalled()
- })
-
- it('genre remove — shows error when genre not found', async () => {
- const interaction = createGenreInteraction('remove', 'jazz')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({ autoplayGenres: ['rock'] })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Not Found',
- expect.any(String),
- )
- })
-
- it('genre list — shows configured genres', async () => {
- const interaction = createGenreInteraction('list')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({
- autoplayGenres: ['rock', 'indie'],
- })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createEmbedMock).toHaveBeenCalledWith(
- expect.objectContaining({
- title: expect.stringContaining('Genres'),
- }),
- )
- })
-
- it('genre list — shows empty state message', async () => {
- const interaction = createGenreInteraction('list')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({ autoplayGenres: [] })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createEmbedMock).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('No genres'),
- }),
- )
- })
-
- it('genre clear — clears all genres', async () => {
- const interaction = createGenreInteraction('clear')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({
- autoplayGenres: ['rock', 'pop'],
- })
- updateGuildSettingsMock.mockResolvedValue(true)
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(updateGuildSettingsMock).toHaveBeenCalledWith('guild-1', {
- autoplayGenres: [],
- })
- })
-
- it('genre clear — shows error when no genres set', async () => {
- const interaction = createGenreInteraction('clear')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({ autoplayGenres: [] })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'No Genres',
- expect.any(String),
- )
- })
-
- it('genre add — handles update failure', async () => {
- const interaction = createGenreInteraction('add', 'rock')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- getGuildSettingsMock.mockResolvedValue({ autoplayGenres: [] })
- updateGuildSettingsMock.mockResolvedValue(false)
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- expect.any(String),
- )
- })
- })
-
- describe('artist subcommand group', () => {
- beforeEach(() => {
- setArtistFeedbackMock.mockResolvedValue(undefined)
- removeArtistFeedbackMock.mockResolvedValue(undefined)
- getArtistFeedbackSummaryMock.mockResolvedValue({
- preferred: [],
- blocked: [],
- })
- })
-
- function createArtistInteraction(subcommand: string, artist?: string) {
- const interaction = {
- guildId: 'guild-1',
- deferred: false,
- replied: false,
- user: { id: 'user-1' },
- deferReply: jest.fn(async () => {
- interaction.deferred = true
- }),
- options: {
- getSubcommand: jest.fn(() => subcommand),
- getSubcommandGroup: jest.fn(() => 'artist'),
- getString: jest.fn(() => artist ?? null),
- },
- }
- return interaction as any
- }
-
- it('artist prefer — marks artist as preferred', async () => {
- const interaction = createArtistInteraction('prefer', 'The Beatles')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(setArtistFeedbackMock).toHaveBeenCalledWith(
- 'guild-1',
- 'user-1',
- 'The Beatles',
- 'prefer',
- )
- expect(createEmbedMock).toHaveBeenCalledWith(
- expect.objectContaining({
- title: expect.stringContaining('Preferred'),
- }),
- )
- })
-
- it('artist block — blocks artist from autoplay', async () => {
- const interaction = createArtistInteraction('block', 'Artist X')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(setArtistFeedbackMock).toHaveBeenCalledWith(
- 'guild-1',
- 'user-1',
- 'Artist X',
- 'block',
- )
- })
-
- it('artist remove — removes preference', async () => {
- const interaction = createArtistInteraction('remove', 'The Beatles')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(removeArtistFeedbackMock).toHaveBeenCalledWith(
- 'guild-1',
- 'user-1',
- 'The Beatles',
- )
- })
-
- it('artist list — shows preferences', async () => {
- const interaction = createArtistInteraction('list')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(createEmbedMock).toHaveBeenCalledWith(
- expect.objectContaining({
- title: expect.stringContaining('Preferences'),
- }),
- )
- })
-
- it('artist prefer — shows error when no artist name', async () => {
- const interaction = createArtistInteraction('prefer', undefined)
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Missing Input',
- expect.any(String),
- )
- })
-
- it('artist block — shows error when no artist name', async () => {
- const interaction = createArtistInteraction('block', undefined)
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Missing Input',
- expect.any(String),
- )
- })
-
- it('artist remove — shows error when no artist name', async () => {
- const interaction = createArtistInteraction('remove', undefined)
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Missing Input',
- expect.any(String),
- )
- })
-
- it('artist prefer — handles service error gracefully', async () => {
- setArtistFeedbackMock.mockRejectedValue(new Error('Redis down'))
- const interaction = createArtistInteraction('prefer', 'The Beatles')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- expect.any(String),
- )
- })
-
- it('artist unknown subcommand — shows error', async () => {
- const interaction = createArtistInteraction('unknown')
- const client = createClient()
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- await autoplayCommand.execute({ client, interaction } as any)
- expect(interactionReplyMock).toHaveBeenCalled()
- })
- })
-
- describe('execute function edge cases', () => {
- it('should return early when guildId is null', async () => {
- const interaction = createInteraction('skip')
- interaction.guildId = null
- const client = createClient()
-
- const result = await autoplayCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(resolveGuildQueueMock).not.toHaveBeenCalled()
- expect(result).toBeUndefined()
- })
-
- it('should handle unknown subcommand', async () => {
- const interaction = createInteraction('unknown')
- interaction.options.getSubcommand = jest
- .fn()
- .mockReturnValue('unknown')
- const client = createClient()
-
- resolveGuildQueueMock.mockReturnValue({ queue: null })
-
- await autoplayCommand.execute({ client, interaction } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Unknown Subcommand',
- 'Please use skip, clear, status, analytics, or mode.',
- )
- expect(interactionReplyMock).toHaveBeenCalled()
- })
- })
})
diff --git a/packages/bot/src/functions/music/commands/autoplay/artistHandlers.spec.ts b/packages/bot/src/functions/music/commands/autoplay/artistHandlers.spec.ts
deleted file mode 100644
index 7c10493a5..000000000
--- a/packages/bot/src/functions/music/commands/autoplay/artistHandlers.spec.ts
+++ /dev/null
@@ -1,382 +0,0 @@
-import { jest } from '@jest/globals'
-import type { ChatInputCommandInteraction } from 'discord.js'
-
-const mockInteractionReply = jest.fn().mockResolvedValue(undefined)
-const mockSetArtistFeedback = jest.fn().mockResolvedValue(undefined)
-const mockRemoveArtistFeedback = jest.fn().mockResolvedValue(undefined)
-const mockGetArtistFeedbackSummary = jest.fn()
-const mockCreateEmbed = jest.fn().mockReturnValue({ type: 'embed' })
-const mockCreateErrorEmbed = jest.fn().mockReturnValue({ type: 'error-embed' })
-const mockErrorLog = jest.fn()
-
-jest.mock('../../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => mockInteractionReply(...args),
-}))
-
-jest.mock('../../../../services/musicRecommendation/feedbackService', () => ({
- recommendationFeedbackService: {
- setArtistFeedback: (...args: unknown[]) => mockSetArtistFeedback(...args),
- removeArtistFeedback: (...args: unknown[]) => mockRemoveArtistFeedback(...args),
- getArtistFeedbackSummary: (...args: unknown[]) => mockGetArtistFeedbackSummary(...args),
- },
-}))
-
-jest.mock('../../../../utils/general/embeds', () => ({
- createEmbed: (...args: unknown[]) => mockCreateEmbed(...args),
- createErrorEmbed: (...args: unknown[]) => mockCreateErrorEmbed(...args),
- EMBED_COLORS: { AUTOPLAY: '#7289DA', ERROR: '#FF0000' },
- EMOJIS: { AUTOPLAY: '📻' },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: (...args: unknown[]) => mockErrorLog(...args),
-}))
-
-import { handleAutoplayArtist } from './artistHandlers'
-
-function makeInteraction(
- subcommand: string,
- artistName: string | null = null,
- guildId: string | null = 'guild-123',
-): ChatInputCommandInteraction {
- return {
- guildId,
- user: { id: 'user-1' },
- options: {
- getSubcommand: () => subcommand,
- getString: (_key: string) => artistName,
- },
- } as unknown as ChatInputCommandInteraction
-}
-
-describe('handleAutoplayArtist', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- describe('missing guildId', () => {
- it('shows error embed and does not call service', async () => {
- const interaction = makeInteraction('prefer', 'artist', null)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Guild Not Found',
- 'Unable to retrieve guild information.',
- )
- expect(mockInteractionReply).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- ephemeral: true,
- }),
- }),
- )
- expect(mockSetArtistFeedback).not.toHaveBeenCalled()
- expect(mockRemoveArtistFeedback).not.toHaveBeenCalled()
- })
- })
-
- describe('subcommand: prefer', () => {
- it('shows missing input error when artist name is null', async () => {
- const interaction = makeInteraction('prefer', null)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Missing Input',
- 'Please provide an artist name.',
- )
- expect(mockSetArtistFeedback).not.toHaveBeenCalled()
- })
-
- it('calls setArtistFeedback with prefer and shows success embed', async () => {
- const interaction = makeInteraction('prefer', 'The Beatles')
- mockCreateEmbed.mockReturnValueOnce({ type: 'success' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockSetArtistFeedback).toHaveBeenCalledWith(
- 'guild-123',
- 'user-1',
- 'The Beatles',
- 'prefer',
- )
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '⭐ Artist Preferred',
- color: '#7289DA',
- }),
- )
- expect(mockInteractionReply).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- ephemeral: true,
- }),
- }),
- )
- })
- })
-
- describe('subcommand: block', () => {
- it('shows missing input error when artist name is null', async () => {
- const interaction = makeInteraction('block', null)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Missing Input',
- 'Please provide an artist name.',
- )
- expect(mockSetArtistFeedback).not.toHaveBeenCalled()
- })
-
- it('calls setArtistFeedback with block and shows success embed with error color', async () => {
- const interaction = makeInteraction('block', 'Nickelback')
- mockCreateEmbed.mockReturnValueOnce({ type: 'success' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockSetArtistFeedback).toHaveBeenCalledWith(
- 'guild-123',
- 'user-1',
- 'Nickelback',
- 'block',
- )
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '🚫 Artist Blocked',
- color: '#FF0000',
- }),
- )
- expect(mockInteractionReply).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- ephemeral: true,
- }),
- }),
- )
- })
- })
-
- describe('subcommand: remove', () => {
- it('shows missing input error when artist name is null', async () => {
- const interaction = makeInteraction('remove', null)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Missing Input',
- 'Please provide an artist name.',
- )
- expect(mockRemoveArtistFeedback).not.toHaveBeenCalled()
- })
-
- it('calls removeArtistFeedback and shows success embed', async () => {
- const interaction = makeInteraction('remove', 'Drake')
- mockCreateEmbed.mockReturnValueOnce({ type: 'success' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockRemoveArtistFeedback).toHaveBeenCalledWith(
- 'guild-123',
- 'user-1',
- 'Drake',
- )
- expect(mockSetArtistFeedback).not.toHaveBeenCalled()
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '✓ Preference Removed',
- color: '#7289DA',
- }),
- )
- })
- })
-
- describe('subcommand: list', () => {
- it('shows no preferred and no blocked artists when both are empty', async () => {
- const interaction = makeInteraction('list')
- mockGetArtistFeedbackSummary.mockResolvedValueOnce({
- preferred: [],
- blocked: [],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'list' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockGetArtistFeedbackSummary).toHaveBeenCalledWith('user-1')
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '🎯 Your Artist Preferences',
- description: expect.stringContaining('No preferred artists.'),
- }),
- )
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('No blocked artists.'),
- }),
- )
- })
-
- it('maps preferred artists with star emoji', async () => {
- const interaction = makeInteraction('list')
- mockGetArtistFeedbackSummary.mockResolvedValueOnce({
- preferred: ['Beatles', 'Pink Floyd'],
- blocked: [],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'list' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('⭐ Beatles'),
- }),
- )
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('⭐ Pink Floyd'),
- }),
- )
- })
-
- it('maps blocked artists with blocked emoji', async () => {
- const interaction = makeInteraction('list')
- mockGetArtistFeedbackSummary.mockResolvedValueOnce({
- preferred: [],
- blocked: ['Cardi B', 'Lil Pump'],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'list' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('🚫 Cardi B'),
- }),
- )
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('🚫 Lil Pump'),
- }),
- )
- })
-
- it('shows both preferred and blocked artists with correct format', async () => {
- const interaction = makeInteraction('list')
- mockGetArtistFeedbackSummary.mockResolvedValueOnce({
- preferred: ['Adele'],
- blocked: ['Imagine Dragons'],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'list' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('⭐ Adele'),
- }),
- )
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('🚫 Imagine Dragons'),
- }),
- )
- })
- })
-
- describe('unknown subcommand', () => {
- it('shows unknown subcommand error embed', async () => {
- const interaction = makeInteraction('invalid')
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Unknown Subcommand',
- 'This subcommand is not recognized.',
- )
- expect(mockInteractionReply).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- ephemeral: true,
- }),
- }),
- )
- })
- })
-
- describe('error handling', () => {
- it('logs error and shows error embed when setArtistFeedback throws', async () => {
- const interaction = makeInteraction('prefer', 'Radiohead')
- const error = new Error('Service unavailable')
- mockSetArtistFeedback.mockRejectedValueOnce(error)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockErrorLog).toHaveBeenCalledWith({
- message: 'Failed to handle artist preference',
- error,
- data: { guildId: 'guild-123', userId: 'user-1', subcommandName: 'prefer' },
- })
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Error',
- 'Failed to update artist preferences.',
- )
- })
-
- it('logs error and shows error embed when removeArtistFeedback throws', async () => {
- const interaction = makeInteraction('remove', 'Taylor Swift')
- const error = new Error('Database error')
- mockRemoveArtistFeedback.mockRejectedValueOnce(error)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockErrorLog).toHaveBeenCalledWith({
- message: 'Failed to handle artist preference',
- error,
- data: { guildId: 'guild-123', userId: 'user-1', subcommandName: 'remove' },
- })
- })
-
- it('logs error and shows error embed when getArtistFeedbackSummary throws', async () => {
- const interaction = makeInteraction('list')
- const error = new Error('Fetch failed')
- mockGetArtistFeedbackSummary.mockRejectedValueOnce(error)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockErrorLog).toHaveBeenCalledWith({
- message: 'Failed to handle artist preference',
- error,
- data: { guildId: 'guild-123', userId: 'user-1', subcommandName: 'list' },
- })
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Error',
- 'Failed to update artist preferences.',
- )
- })
-
- it('includes correct subcommand name in error log for block', async () => {
- const interaction = makeInteraction('block', 'TestArtist')
- mockSetArtistFeedback.mockRejectedValueOnce(new Error('API error'))
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayArtist(interaction)
-
- expect(mockErrorLog).toHaveBeenCalledWith(
- expect.objectContaining({
- data: expect.objectContaining({
- subcommandName: 'block',
- }),
- }),
- )
- })
- })
-})
diff --git a/packages/bot/src/functions/music/commands/autoplay/settingsHandlers.spec.ts b/packages/bot/src/functions/music/commands/autoplay/settingsHandlers.spec.ts
deleted file mode 100644
index 42022b448..000000000
--- a/packages/bot/src/functions/music/commands/autoplay/settingsHandlers.spec.ts
+++ /dev/null
@@ -1,588 +0,0 @@
-import { jest } from '@jest/globals'
-import type { ChatInputCommandInteraction } from 'discord.js'
-
-const mockInteractionReply = jest.fn().mockResolvedValue(undefined)
-const mockCreateEmbed = jest.fn().mockReturnValue({ type: 'embed' })
-const mockCreateErrorEmbed = jest.fn().mockReturnValue({ type: 'error-embed' })
-const mockGetGuildSettings = jest.fn()
-const mockUpdateGuildSettings = jest.fn()
-const mockGetAutoplayStats = jest.fn()
-
-jest.mock('../../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => mockInteractionReply(...args),
-}))
-
-jest.mock('../../../../utils/general/embeds', () => ({
- createEmbed: (...args: unknown[]) => mockCreateEmbed(...args),
- createErrorEmbed: (...args: unknown[]) => mockCreateErrorEmbed(...args),
- EMBED_COLORS: { AUTOPLAY: '#7289DA', ERROR: '#FF0000' },
- EMOJIS: { AUTOPLAY: '📻' },
-}))
-
-jest.mock('@lucky/shared/services', () => ({
- guildSettingsService: {
- getGuildSettings: (...args: unknown[]) => mockGetGuildSettings(...args),
- updateGuildSettings: (...args: unknown[]) => mockUpdateGuildSettings(...args),
- },
- trackHistoryService: {
- getAutoplayStats: (...args: unknown[]) => mockGetAutoplayStats(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: jest.fn(),
-}))
-
-import {
- handleAutoplayMode,
- handleAutoplayGenre,
- handleAutoplayAnalytics,
-} from './settingsHandlers'
-
-function makeInteraction(overrides: object = {}): ChatInputCommandInteraction {
- return {
- guildId: 'guild-123',
- user: { id: 'user-1' },
- options: {
- getString: jest.fn().mockReturnValue(null),
- getSubcommand: jest.fn().mockReturnValue(null),
- },
- ...overrides,
- } as unknown as ChatInputCommandInteraction
-}
-
-describe('handleAutoplayMode', () => {
- const baseInteraction = makeInteraction()
-
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('returns error when guildId is missing', async () => {
- const interaction = makeInteraction({ guildId: null })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayMode(interaction)
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Guild Not Found',
- 'Unable to retrieve guild information.',
- )
- expect(mockInteractionReply).toHaveBeenCalled()
- })
-
- it('shows current mode when no mode parameter provided', async () => {
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayMode: 'discover',
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayMode(baseInteraction)
-
- expect(mockGetGuildSettings).toHaveBeenCalledWith('guild-123')
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '🎚️ Autoplay Mode',
- description: expect.stringContaining('discover'),
- }),
- )
- })
-
- it('defaults to similar mode when no settings exist', async () => {
- mockGetGuildSettings.mockResolvedValueOnce(null)
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayMode(baseInteraction)
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('similar'),
- }),
- )
- })
-
- it('updates mode when mode parameter provided', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue('popular'),
- },
- })
- mockUpdateGuildSettings.mockResolvedValueOnce(true)
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayMode(interaction)
-
- expect(mockUpdateGuildSettings).toHaveBeenCalledWith('guild-123', {
- autoplayMode: 'popular',
- })
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '✅ Autoplay mode updated',
- }),
- )
- })
-
- it('shows error when update fails', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue('similar'),
- },
- })
- mockUpdateGuildSettings.mockResolvedValueOnce(false)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayMode(interaction)
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Error',
- 'Failed to update autoplay mode.',
- )
- })
-})
-
-describe('handleAutoplayGenre', () => {
- const baseInteraction = makeInteraction()
-
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- describe('add subcommand', () => {
- it('returns error when guildId is missing', async () => {
- const interaction = makeInteraction({ guildId: null })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(interaction, 'add')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Guild Not Found',
- 'Unable to retrieve guild information.',
- )
- })
-
- it('returns error when tag is not provided', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue(null),
- },
- })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(interaction, 'add')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Missing Tag',
- 'Please provide a genre tag.',
- )
- })
-
- it('returns error when max genres reached', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue('pop'),
- },
- })
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: ['rock', 'jazz', 'pop', 'metal', 'classical'],
- })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(interaction, 'add')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Limit Reached',
- 'Maximum 5 genres allowed per guild.',
- )
- })
-
- it('returns error when genre already exists', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue('rock'),
- },
- })
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: ['rock', 'jazz'],
- })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(interaction, 'add')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Already Added',
- 'Genre **rock** is already in your list.',
- )
- })
-
- it('adds genre successfully', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue('pop'),
- },
- })
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: ['rock'],
- })
- mockUpdateGuildSettings.mockResolvedValueOnce(true)
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayGenre(interaction, 'add')
-
- expect(mockUpdateGuildSettings).toHaveBeenCalledWith('guild-123', {
- autoplayGenres: ['rock', 'pop'],
- })
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '✅ Genre added',
- }),
- )
- })
-
- it('shows error when update fails', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue('pop'),
- },
- })
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: [],
- })
- mockUpdateGuildSettings.mockResolvedValueOnce(false)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(interaction, 'add')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Error',
- 'Failed to add genre.',
- )
- })
- })
-
- describe('remove subcommand', () => {
- it('returns error when guildId is missing', async () => {
- const interaction = makeInteraction({ guildId: null })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(interaction, 'remove')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Guild Not Found',
- 'Unable to retrieve guild information.',
- )
- })
-
- it('returns error when tag is not provided', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue(null),
- },
- })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(interaction, 'remove')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Missing Tag',
- 'Please provide a genre tag.',
- )
- })
-
- it('returns error when genre not found', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue('pop'),
- },
- })
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: ['rock', 'jazz'],
- })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(interaction, 'remove')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Not Found',
- 'Genre **pop** is not in your list.',
- )
- })
-
- it('removes genre successfully', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue('rock'),
- },
- })
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: ['rock', 'jazz', 'pop'],
- })
- mockUpdateGuildSettings.mockResolvedValueOnce(true)
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayGenre(interaction, 'remove')
-
- expect(mockUpdateGuildSettings).toHaveBeenCalledWith('guild-123', {
- autoplayGenres: ['jazz', 'pop'],
- })
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '✅ Genre removed',
- }),
- )
- })
-
- it('shows error when update fails', async () => {
- const interaction = makeInteraction({
- options: {
- getString: jest.fn().mockReturnValue('rock'),
- },
- })
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: ['rock'],
- })
- mockUpdateGuildSettings.mockResolvedValueOnce(false)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(interaction, 'remove')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Error',
- 'Failed to remove genre.',
- )
- })
- })
-
- describe('list subcommand', () => {
- it('shows error when guildId is missing', async () => {
- const interaction = makeInteraction({ guildId: null })
-
- await handleAutoplayGenre(interaction, 'list')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith('Guild Not Found', 'Unable to retrieve guild information.')
- expect(mockInteractionReply).toHaveBeenCalledTimes(1)
- })
-
- it('shows empty genres message when no genres configured', async () => {
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: [],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayGenre(baseInteraction, 'list')
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '🎵 Autoplay Genres',
- description: 'No genres configured yet.',
- }),
- )
- })
-
- it('shows all genres with numbered list', async () => {
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: ['rock', 'jazz', 'pop'],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayGenre(baseInteraction, 'list')
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('1. rock'),
- }),
- )
- })
- })
-
- describe('clear subcommand', () => {
- it('shows error when guildId is missing', async () => {
- const interaction = makeInteraction({ guildId: null })
-
- await handleAutoplayGenre(interaction, 'clear')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith('Guild Not Found', 'Unable to retrieve guild information.')
- expect(mockInteractionReply).toHaveBeenCalledTimes(1)
- })
-
- it('shows error when no genres to clear', async () => {
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: [],
- })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(baseInteraction, 'clear')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'No Genres',
- 'No genres to clear.',
- )
- })
-
- it('clears all genres successfully', async () => {
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: ['rock', 'jazz'],
- })
- mockUpdateGuildSettings.mockResolvedValueOnce(true)
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayGenre(baseInteraction, 'clear')
-
- expect(mockUpdateGuildSettings).toHaveBeenCalledWith('guild-123', {
- autoplayGenres: [],
- })
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '✅ Genres cleared',
- }),
- )
- })
-
- it('shows error when clear fails', async () => {
- mockGetGuildSettings.mockResolvedValueOnce({
- autoplayGenres: ['rock'],
- })
- mockUpdateGuildSettings.mockResolvedValueOnce(false)
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(baseInteraction, 'clear')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Error',
- 'Failed to clear genres.',
- )
- })
- })
-
- it('shows error for unknown subcommand', async () => {
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayGenre(baseInteraction, 'unknown')
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Unknown Subcommand',
- 'That genre subcommand is not recognized.',
- )
- })
-})
-
-describe('handleAutoplayAnalytics', () => {
- const baseInteraction = makeInteraction()
-
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('returns error when guildId is missing', async () => {
- const interaction = makeInteraction({ guildId: null })
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayAnalytics(interaction)
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Guild Not Found',
- 'Unable to retrieve guild information.',
- )
- })
-
- it('shows no play history message when stats total is zero', async () => {
- mockGetAutoplayStats.mockResolvedValueOnce({ total: 0 })
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayAnalytics(baseInteraction)
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '📈 Autoplay Analytics',
- description: 'No play history yet.',
- }),
- )
- })
-
- it('shows analytics with top artists when stats exist', async () => {
- mockGetAutoplayStats.mockResolvedValueOnce({
- total: 100,
- autoplayCount: 75,
- autoplayPercent: 75,
- topAutoplayArtists: [
- { artist: 'The Beatles', count: 15 },
- { artist: 'Pink Floyd', count: 10 },
- ],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayAnalytics(baseInteraction)
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- title: '📈 Autoplay Analytics',
- description: expect.stringContaining('100'),
- }),
- )
- })
-
- it('shows correct plural form for single play', async () => {
- mockGetAutoplayStats.mockResolvedValueOnce({
- total: 10,
- autoplayCount: 1,
- autoplayPercent: 10,
- topAutoplayArtists: [{ artist: 'Artist', count: 1 }],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayAnalytics(baseInteraction)
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('1 play'),
- }),
- )
- })
-
- it('shows correct plural form for multiple plays', async () => {
- mockGetAutoplayStats.mockResolvedValueOnce({
- total: 10,
- autoplayCount: 5,
- autoplayPercent: 50,
- topAutoplayArtists: [{ artist: 'Artist', count: 5 }],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayAnalytics(baseInteraction)
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('5 plays'),
- }),
- )
- })
-
- it('shows no autoplay data message when topAutoplayArtists is empty', async () => {
- mockGetAutoplayStats.mockResolvedValueOnce({
- total: 50,
- autoplayCount: 0,
- autoplayPercent: 0,
- topAutoplayArtists: [],
- })
- mockCreateEmbed.mockReturnValueOnce({ type: 'embed' })
-
- await handleAutoplayAnalytics(baseInteraction)
-
- expect(mockCreateEmbed).toHaveBeenCalledWith(
- expect.objectContaining({
- description: expect.stringContaining('No autoplay data yet.'),
- }),
- )
- })
-
- it('handles service error gracefully', async () => {
- mockGetAutoplayStats.mockRejectedValueOnce(new Error('Service error'))
- mockCreateErrorEmbed.mockReturnValueOnce({ type: 'error' })
-
- await handleAutoplayAnalytics(baseInteraction)
-
- expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
- 'Error',
- 'Failed to retrieve autoplay analytics.',
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/clear.spec.ts b/packages/bot/src/functions/music/commands/clear.spec.ts
deleted file mode 100644
index 781cb747b..000000000
--- a/packages/bot/src/functions/music/commands/clear.spec.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import clearCommand from './clear'
-
-const requireGuildMock = jest.fn()
-const requireQueueMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
-}))
-const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
-}))
-const debugLogMock = jest.fn()
-const errorLogMock = jest.fn()
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- debugLog: (...args: unknown[]) => debugLogMock(...args),
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(guildId = 'guild-1') {
- return {
- guildId,
- user: { id: 'user-1' },
- } as any
-}
-
-function createQueue(trackCount = 10) {
- return {
- guild: { id: 'guild-1' },
- tracks: {
- size: trackCount,
- },
- clear: jest.fn(),
- } as any
-}
-
-function createClient() {
- return {
- player: {
- nodes: {
- get: jest.fn(),
- },
- },
- } as any
-}
-
-describe('clear command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- })
-
- it('clears the queue and sends success response with track count', async () => {
- const queue = createQueue(10)
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await clearCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.clear).toHaveBeenCalled()
- expect(debugLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Cleared 10 tracks'),
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- interaction,
- }),
- )
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Queue cleared',
- '🗑️ Removed 10 songs from the queue!',
- )
- })
-
- it('shows error when queue is already empty', async () => {
- const queue = createQueue(0)
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await clearCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.clear).not.toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- ephemeral: true,
- }),
- }),
- )
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Empty queue',
- '🗑️ The queue is already empty!',
- )
- })
-
- it('returns early when guild validation fails', async () => {
- requireGuildMock.mockResolvedValue(false)
- const queue = createQueue(10)
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await clearCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.clear).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when queue validation fails', async () => {
- requireQueueMock.mockResolvedValue(false)
- const queue = createQueue(10)
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await clearCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.clear).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('handles error thrown by discord-player gracefully', async () => {
- const queue = createQueue(10)
- const clearError = new Error('Discord player error')
- queue.clear.mockImplementation(() => {
- throw clearError
- })
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await clearCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Error in clear command:',
- error: clearError,
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- ephemeral: true,
- }),
- }),
- )
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- '🔄 An error occurred while clearing the queue!',
- )
- })
-
- it('clears queues with single track', async () => {
- const queue = createQueue(1)
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await clearCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.clear).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Queue cleared',
- '🗑️ Removed 1 songs from the queue!',
- )
- })
-
- it('clears large queues with many tracks', async () => {
- const queue = createQueue(500)
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await clearCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.clear).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Queue cleared',
- '🗑️ Removed 500 songs from the queue!',
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/effects.spec.ts b/packages/bot/src/functions/music/commands/effects.spec.ts
deleted file mode 100644
index 508d1006d..000000000
--- a/packages/bot/src/functions/music/commands/effects.spec.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import effectsCommand from './effects'
-
-const requireGuildMock = jest.fn()
-const requireDJRoleMock = jest.fn()
-const requireQueueMock = jest.fn()
-const requireIsPlayingMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
- color: 0x00ff00,
-}))
-const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
- color: 0xff0000,
-}))
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireIsPlaying: (...args: unknown[]) => requireIsPlayingMock(...args),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args)
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(guildId = 'guild-1', subcommand = 'bassboost', level?: number) {
- const interaction = {
- guildId,
- options: {
- getSubcommand: jest.fn().mockReturnValue(subcommand),
- getInteger: jest.fn((name) => {
- if (name === 'level') return level ?? 2
- return null
- }),
- },
- } as any
- return interaction
-}
-
-function createQueue() {
- return {
- isPlaying: jest.fn().mockReturnValue(true),
- filters: {
- ffmpeg: {
- toggle: jest.fn(),
- setFilters: jest.fn(),
- },
- resampler: {
- toggleFilter: jest.fn().mockReturnValue(false),
- },
- },
- } as any
-}
-
-describe('effects command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- requireDJRoleMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- requireIsPlayingMock.mockResolvedValue(true)
- })
-
- it('returns early when guild validation fails', async () => {
- requireGuildMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when queue validation fails', async () => {
- requireQueueMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when music is not playing', async () => {
- requireIsPlayingMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('applies bass boost level', async () => {
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({
- client: {} as any,
- interaction: createInteraction('guild-1', 'bassboost', 3),
- } as any)
-
- expect(queue.filters.ffmpeg.toggle).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('Bass boost', expect.stringContaining('Bass boost level set to 3'))
- })
-
- it('rejects invalid bass boost level', async () => {
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({
- client: {} as any,
- interaction: createInteraction('guild-1', 'bassboost', 10),
- } as any)
-
- expect(queue.filters.ffmpeg.toggle).not.toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Error', expect.stringContaining('must be between 0 and 5'))
- })
-
- it('toggles nightcore effect', async () => {
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({
- client: {} as any,
- interaction: createInteraction('guild-1', 'nightcore'),
- } as any)
-
- expect(queue.filters.resampler.toggleFilter).toHaveBeenCalledWith('nightcore')
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('Nightcore', expect.any(String))
- })
-
- it('resets all effects', async () => {
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({
- client: {} as any,
- interaction: createInteraction('guild-1', 'reset'),
- } as any)
-
- expect(queue.filters.ffmpeg.setFilters).toHaveBeenCalledWith([])
- expect(queue.filters.resampler.toggleFilter).toHaveBeenCalledWith('nightcore')
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('Effects reset', expect.stringContaining('All effects have been cleared'))
- })
-
- it('disables bass boost when level is 0 (double-toggle)', async () => {
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({
- client: {} as any,
- interaction: createInteraction('guild-1', 'bassboost', 0),
- } as any)
-
- expect(queue.filters.ffmpeg.toggle).toHaveBeenCalledTimes(2)
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('Bass boost', expect.stringContaining('disabled'))
- })
-
- it('replies with error when bass boost filter throws', async () => {
- const queue = createQueue()
- queue.filters.ffmpeg.toggle = jest.fn().mockRejectedValue(new Error('filter error'))
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({
- client: {} as any,
- interaction: createInteraction('guild-1', 'bassboost', 2),
- } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Error', expect.stringContaining('Failed to apply bass boost'))
- })
-
- it('replies with error when nightcore toggle throws', async () => {
- const queue = createQueue()
- queue.filters.resampler.toggleFilter = jest.fn().mockImplementation(() => { throw new Error('resampler error') })
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({
- client: {} as any,
- interaction: createInteraction('guild-1', 'nightcore'),
- } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Error', expect.stringContaining('Failed to toggle nightcore'))
- })
-
- it('replies with error when reset throws', async () => {
- const queue = createQueue()
- queue.filters.ffmpeg.setFilters = jest.fn().mockRejectedValue(new Error('reset error'))
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await effectsCommand.execute({
- client: {} as any,
- interaction: createInteraction('guild-1', 'reset'),
- } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Error', expect.stringContaining('Failed to reset'))
- })
-})
diff --git a/packages/bot/src/functions/music/commands/leavecleanup.spec.ts b/packages/bot/src/functions/music/commands/leavecleanup.spec.ts
deleted file mode 100644
index cc9f6e135..000000000
--- a/packages/bot/src/functions/music/commands/leavecleanup.spec.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import leavecleanupCommand from './leavecleanup'
-
-const requireGuildMock = jest.fn()
-const requireDJRoleMock = jest.fn()
-const requireQueueMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
- color: 0x00ff00,
-}))
-const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
- color: 0xff0000,
-}))
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args)
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(guildId = 'guild-1') {
- return {
- guildId,
- } as any
-}
-
-function createQueue(tracks: any[] = []) {
- return {
- channel: {
- members: new Map([
- ['user-1', {}],
- ['user-2', {}],
- ]),
- },
- tracks: {
- toArray: jest.fn().mockReturnValue(tracks),
- },
- node: {
- remove: jest.fn(),
- },
- } as any
-}
-
-function createTrack(id: string, requesterId?: string) {
- return {
- id,
- title: `Track ${id}`,
- author: 'Artist',
- requestedBy: requesterId ? { id: requesterId } : null,
- url: `http://example.com/${id}`,
- } as any
-}
-
-describe('leavecleanup command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- requireDJRoleMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- })
-
- it('returns early when guild validation fails', async () => {
- requireGuildMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await leavecleanupCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when queue validation fails', async () => {
- requireQueueMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await leavecleanupCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('removes tracks from members who left the voice channel', async () => {
- const tracks = [
- createTrack('1', 'user-1'),
- createTrack('2', 'user-3'),
- createTrack('3', 'user-1'),
- createTrack('4', 'user-5'),
- ]
- const queue = createQueue(tracks)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await leavecleanupCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(queue.node.remove).toHaveBeenCalledTimes(2)
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('Queue cleaned up', expect.stringContaining('Removed 2 tracks'))
- })
-
- it('shows message when no tracks need to be removed', async () => {
- const tracks = [
- createTrack('1', 'user-1'),
- createTrack('2', 'user-2'),
- ]
- const queue = createQueue(tracks)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await leavecleanupCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(queue.node.remove).not.toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('Queue cleaned up', expect.stringContaining('all requesters are still in the channel'))
- })
-
- it('shows error message when bot is not in a voice channel', async () => {
- const queue = {
- channel: null,
- tracks: { toArray: jest.fn().mockReturnValue([]) },
- node: { remove: jest.fn() },
- } as any
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await leavecleanupCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Error', expect.stringContaining('not in a voice channel'))
- })
-})
diff --git a/packages/bot/src/functions/music/commands/lyrics.spec.ts b/packages/bot/src/functions/music/commands/lyrics.spec.ts
deleted file mode 100644
index c9fe7c4b9..000000000
--- a/packages/bot/src/functions/music/commands/lyrics.spec.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-
-const interactionReplyMock = jest.fn()
-const followUpMock = jest.fn()
-const deferReplyMock = jest.fn()
-const editReplyMock = jest.fn()
-const requireCurrentTrackMock = jest.fn()
-const resolveGuildQueueMock = jest.fn()
-const searchLyricsMock = jest.fn()
-const splitLyricsMock = jest.fn()
-const featureToggleIsEnabledMock = jest.fn()
-const musicEmbedMock = jest.fn()
-const createErrorEmbedMock = jest.fn()
-const createWarningEmbedMock = jest.fn()
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireCurrentTrack: (...args: unknown[]) =>
- requireCurrentTrackMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: jest.fn(),
-}))
-
-jest.mock('@lucky/shared/services', () => ({
- featureToggleService: {
- isEnabled: (...args: unknown[]) => featureToggleIsEnabledMock(...args),
- },
- lyricsService: {
- searchLyrics: (...args: unknown[]) => searchLyricsMock(...args),
- splitLyrics: (...args: unknown[]) => splitLyricsMock(...args),
- },
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- musicEmbed: (...args: unknown[]) => musicEmbedMock(...args),
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
- createWarningEmbed: (...args: unknown[]) => createWarningEmbedMock(...args),
-}))
-
-import lyricsCommand from './lyrics'
-
-type TExecuteArgs = Parameters[0]
-type TInteraction = TExecuteArgs['interaction']
-type TClient = TExecuteArgs['client']
-
-
-function createInteraction({
- guildId = 'guild-1',
- song = null,
-}: {
- guildId?: string | null
- song?: string | null
-}) {
- const interaction = {
- guildId,
- user: { id: 'user-1' },
- options: {
- getString: jest.fn(() => song),
- },
- deferReply: deferReplyMock,
- editReply: editReplyMock,
- followUp: followUpMock,
- }
- return interaction as unknown as TInteraction
-}
-
-function createEmbed(data: object = {}) {
- return {
- ...data,
- setFooter: jest.fn(function (this: object) {
- return this
- }),
- }
-}
-
-describe('lyrics command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- interactionReplyMock.mockResolvedValue(undefined)
- deferReplyMock.mockResolvedValue(undefined)
- editReplyMock.mockResolvedValue(undefined)
- followUpMock.mockResolvedValue(undefined)
- featureToggleIsEnabledMock.mockResolvedValue(true)
- requireCurrentTrackMock.mockResolvedValue(true)
- resolveGuildQueueMock.mockReturnValue({
- queue: {
- currentTrack: { title: 'Test Song', author: 'Test Artist' },
- },
- })
- musicEmbedMock.mockReturnValue(createEmbed())
- createErrorEmbedMock.mockReturnValue({ type: 'error' })
- createWarningEmbedMock.mockReturnValue({ type: 'warning' })
- })
-
- it('replies with warning when LYRICS feature is disabled', async () => {
- featureToggleIsEnabledMock.mockResolvedValue(false)
- const interaction = createInteraction({})
- const client = {} as unknown as TClient
- await lyricsCommand.execute({ client, interaction })
- expect(createWarningEmbedMock).toHaveBeenCalledWith(
- 'Feature unavailable',
- expect.any(String),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({ ephemeral: true }),
- }),
- )
- })
-
- it('replies with error when no guild', async () => {
- const interaction = createInteraction({ guildId: null })
- const client = {} as unknown as TClient
- await lyricsCommand.execute({ client, interaction })
- expect(createErrorEmbedMock).toHaveBeenCalled()
- })
-
- it('fetches lyrics for explicit song query', async () => {
- const lyricResult = {
- title: 'Test Song',
- artist: 'Test Artist',
- lyrics: 'Line 1\nLine 2',
- source: 'lyrics.ovh',
- }
- searchLyricsMock.mockResolvedValue(lyricResult)
- splitLyricsMock.mockReturnValue(['Line 1\nLine 2'])
- const interaction = createInteraction({ song: 'Test Song' })
- const client = {} as unknown as TClient
- await lyricsCommand.execute({ client, interaction })
- expect(searchLyricsMock).toHaveBeenCalledWith('Test Song', undefined)
- expect(musicEmbedMock).toHaveBeenCalledWith(
- expect.stringContaining('Test Song'),
- 'Line 1\nLine 2',
- )
- expect(deferReplyMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(followUpMock).not.toHaveBeenCalled()
- })
-
- it('uses current track when no song query given', async () => {
- const lyricResult = {
- title: 'Test Song',
- artist: 'Test Artist',
- lyrics: 'Verse',
- source: 'lyrics.ovh',
- }
- searchLyricsMock.mockResolvedValue(lyricResult)
- splitLyricsMock.mockReturnValue(['Verse'])
- const interaction = createInteraction({ song: null })
- const client = {} as unknown as TClient
- await lyricsCommand.execute({ client, interaction })
- expect(searchLyricsMock).toHaveBeenCalledWith(
- 'Test Song',
- 'Test Artist',
- )
- })
-
- it('sends multiple follow-ups for paginated lyrics', async () => {
- const lyricResult = {
- title: 'Long Song',
- artist: 'Artist',
- lyrics: 'A'.repeat(4000),
- source: 'ovh',
- }
- searchLyricsMock.mockResolvedValue(lyricResult)
- splitLyricsMock.mockReturnValue(['page1', 'page2', 'page3'])
- const interaction = createInteraction({ song: 'Long Song' })
- const client = {} as unknown as TClient
- await lyricsCommand.execute({ client, interaction })
- expect(interactionReplyMock).toHaveBeenCalledTimes(1)
- expect(followUpMock).toHaveBeenCalledTimes(2)
- })
-
- it('replies with error when lyrics provider returns an error', async () => {
- searchLyricsMock.mockResolvedValue({
- error: 'not_found',
- message: 'Lyrics not found',
- })
- const interaction = createInteraction({ song: 'Unknown Song' })
- const client = {} as unknown as TClient
- await lyricsCommand.execute({ client, interaction })
- expect(deferReplyMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Lyrics not found',
- 'Lyrics not found',
- )
- })
-
- it('replies with error on unexpected exception', async () => {
- searchLyricsMock.mockRejectedValue(new Error('Network error'))
- const interaction = createInteraction({ song: 'Any Song' })
- const client = {} as unknown as TClient
- await lyricsCommand.execute({ client, interaction })
- expect(deferReplyMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Lyrics error',
- expect.any(String),
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/music.spec.ts b/packages/bot/src/functions/music/commands/music.spec.ts
index 48cfccfff..acdcc1154 100644
--- a/packages/bot/src/functions/music/commands/music.spec.ts
+++ b/packages/bot/src/functions/music/commands/music.spec.ts
@@ -117,17 +117,6 @@ describe('music command', () => {
})
})
- it('returns immediately when guild validation fails', async () => {
- requireGuildMock.mockResolvedValue(false)
-
- await musicCommand.execute({
- client: createClient(),
- interaction: createInteraction(),
- } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
it('replies with error for unknown subcommand', async () => {
await musicCommand.execute({
client: createClient(),
@@ -374,14 +363,5 @@ describe('music command', () => {
)
})
- it('clearfeedback does not call health lookup methods', async () => {
- await musicCommand.execute({
- client: createClient(),
- interaction: createInteraction('clearfeedback'),
- } as any)
- expect(getFeedbackCountsMock).not.toHaveBeenCalled()
- expect(getAllStatusesMock).not.toHaveBeenCalled()
- expect(getSnapshotMock).not.toHaveBeenCalled()
- })
})
diff --git a/packages/bot/src/functions/music/commands/pause.spec.ts b/packages/bot/src/functions/music/commands/pause.spec.ts
deleted file mode 100644
index fee8f2f75..000000000
--- a/packages/bot/src/functions/music/commands/pause.spec.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import pauseCommand from './pause'
-
-const requireQueueMock = jest.fn()
-const requireVoiceChannelMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc }))
-const buildCommandTrackEmbedMock = jest.fn(() => ({}))
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireVoiceChannel: (...args: unknown[]) => requireVoiceChannelMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/general/responseEmbeds', () => ({
- buildCommandTrackEmbed: (...args: unknown[]) => buildCommandTrackEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(guildId = 'guild-1') {
- return {
- guildId,
- user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') },
- } as any
-}
-
-function createQueue(isPaused = false, currentTrack: unknown = null) {
- return {
- node: {
- isPaused: jest.fn().mockReturnValue(isPaused),
- pause: jest.fn(),
- resume: jest.fn(),
- },
- currentTrack,
- } as any
-}
-
-function createClient() {
- return {} as any
-}
-
-describe('pause command (toggle)', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireVoiceChannelMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- buildCommandTrackEmbedMock.mockReturnValue({})
- })
-
- it('returns early when voice channel validation fails', async () => {
- requireVoiceChannelMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.pause).not.toHaveBeenCalled()
- expect(queue.node.resume).not.toHaveBeenCalled()
- })
-
- it('returns early when queue validation fails', async () => {
- requireQueueMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.pause).not.toHaveBeenCalled()
- expect(queue.node.resume).not.toHaveBeenCalled()
- })
-
- it('resumes when currently paused with no track', async () => {
- const queue = createQueue(true, null)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.resume).toHaveBeenCalled()
- expect(queue.node.pause).not.toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('▶️ Resumed', 'Music has been resumed.')
- })
-
- it('pauses when currently playing with no track', async () => {
- const queue = createQueue(false, null)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.pause).toHaveBeenCalled()
- expect(queue.node.resume).not.toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('⏸️ Paused', 'Music has been paused.')
- })
-
- it('resumes when currently paused with current track', async () => {
- const currentTrack = { id: 'track-1', title: 'Test Song' }
- const queue = createQueue(true, currentTrack)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.resume).toHaveBeenCalled()
- expect(queue.node.pause).not.toHaveBeenCalled()
- expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(currentTrack, '▶️ Resumed', expect.any(Object))
- })
-
- it('pauses when currently playing with current track', async () => {
- const currentTrack = { id: 'track-1', title: 'Test Song' }
- const queue = createQueue(false, currentTrack)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.pause).toHaveBeenCalled()
- expect(queue.node.resume).not.toHaveBeenCalled()
- expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(currentTrack, '⏸️ Paused', expect.any(Object))
- })
-})
diff --git a/packages/bot/src/functions/music/commands/play/index.spec.ts b/packages/bot/src/functions/music/commands/play/index.spec.ts
index 9e5ad95be..160207af2 100644
--- a/packages/bot/src/functions/music/commands/play/index.spec.ts
+++ b/packages/bot/src/functions/music/commands/play/index.spec.ts
@@ -518,68 +518,6 @@ describe('play command', () => {
)
})
- it('calls moveUserTrackToPriority even when track url already exists in queue', async () => {
- const interaction = createInteraction('guild-1')
- const track = {
- url: 'https://example.com/url-match',
- title: 'Song A',
- author: 'Artist A',
- }
-
- resolveGuildQueueMock.mockReturnValue({
- queue: {
- repeatMode: 3,
- tracks: {
- size: 1,
- toArray: () => [
- {
- url: 'https://example.com/url-match',
- title: 'Queued',
- },
- ],
- },
- },
- })
-
- await playCommand.execute({
- client: createClient(async () => ({
- track,
- searchResult: { playlist: null, tracks: [] },
- })),
- interaction,
- } as any)
-
- expect(moveUserTrackToPriorityMock).toHaveBeenCalled()
- })
-
- it('calls moveUserTrackToPriority even when same track object is already queued', async () => {
- const interaction = createInteraction('guild-1')
- const track = {
- title: 'Song A',
- author: 'Artist A',
- }
-
- resolveGuildQueueMock.mockReturnValue({
- queue: {
- repeatMode: 3,
- tracks: {
- size: 1,
- toArray: () => [track],
- },
- },
- })
-
- await playCommand.execute({
- client: createClient(async () => ({
- track,
- searchResult: { playlist: null, tracks: [] },
- })),
- interaction,
- } as any)
-
- expect(moveUserTrackToPriorityMock).toHaveBeenCalled()
- })
-
it('stops when voice channel validation fails', async () => {
requireVoiceChannelMock.mockResolvedValue(false)
const interaction = createInteraction('guild-1')
diff --git a/packages/bot/src/functions/music/commands/playlist.spec.ts b/packages/bot/src/functions/music/commands/playlist.spec.ts
deleted file mode 100644
index 2d9c954f0..000000000
--- a/packages/bot/src/functions/music/commands/playlist.spec.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import playlistCommand from './playlist'
-
-const interactionReplyMock = jest.fn()
-const requireGuildMock = jest.fn()
-const getStateMock = jest.fn()
-const resetContributionsMock = jest.fn()
-const setModeMock = jest.fn()
-const createInfoEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'info',
- title,
- message,
-}))
-const createSuccessEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'success',
- title,
- message,
-}))
-const createWarningEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'warning',
- title,
- message,
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
-}))
-
-jest.mock('../../../utils/music/collaborativePlaylist', () => ({
- collaborativePlaylistService: {
- getState: (...args: unknown[]) => getStateMock(...args),
- resetContributions: (...args: unknown[]) =>
- resetContributionsMock(...args),
- setMode: (...args: unknown[]) => setModeMock(...args),
- },
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createInfoEmbed: (...args: unknown[]) => createInfoEmbedMock(...args),
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
- createWarningEmbed: (...args: unknown[]) => createWarningEmbedMock(...args),
-}))
-
-function createInteraction(action: string, guildId = 'guild-1', limit?: number) {
- return {
- guildId,
- options: {
- getString: jest.fn(() => action),
- getInteger: jest.fn(() => limit ?? null),
- },
- } as any
-}
-
-describe('playlist command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- getStateMock.mockReturnValue({
- enabled: true,
- perUserLimit: 3,
- contributions: { userA: 2 },
- })
- setModeMock.mockReturnValue({ perUserLimit: 4 })
- })
-
- it('returns early when guild validation fails', async () => {
- requireGuildMock.mockResolvedValue(false)
-
- await playlistCommand.execute({ interaction: createInteraction('status') } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns status with contribution list', async () => {
- await playlistCommand.execute({ interaction: createInteraction('status') } as any)
-
- expect(getStateMock).toHaveBeenCalledWith('guild-1')
- expect(createInfoEmbedMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({ ephemeral: true }),
- }),
- )
- })
-
- it('resets contributions', async () => {
- await playlistCommand.execute({ interaction: createInteraction('reset') } as any)
-
- expect(resetContributionsMock).toHaveBeenCalledWith('guild-1')
- expect(createWarningEmbedMock).toHaveBeenCalled()
- })
-
- it('enables collaborative mode with provided limit', async () => {
- await playlistCommand.execute({
- interaction: createInteraction('enable', 'guild-1', 4),
- } as any)
-
- expect(setModeMock).toHaveBeenCalledWith('guild-1', true, 4)
- expect(createSuccessEmbedMock).toHaveBeenCalled()
- })
-
- it('disables collaborative mode', async () => {
- await playlistCommand.execute({
- interaction: createInteraction('disable'),
- } as any)
-
- expect(setModeMock).toHaveBeenCalledWith('guild-1', false)
- expect(createWarningEmbedMock).toHaveBeenCalled()
- })
-})
diff --git a/packages/bot/src/functions/music/commands/queue/index.spec.ts b/packages/bot/src/functions/music/commands/queue/index.spec.ts
deleted file mode 100644
index 6b9f61911..000000000
--- a/packages/bot/src/functions/music/commands/queue/index.spec.ts
+++ /dev/null
@@ -1,243 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import queueCommand from './index'
-
-const debugLogMock = jest.fn()
-const errorLogMock = jest.fn()
-const requireGuildMock = jest.fn()
-const requireQueueMock = jest.fn()
-const createQueueEmbedMock = jest.fn()
-const createQueueErrorEmbedMock = jest.fn()
-const createErrorEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'error',
- title,
- message,
-}))
-const createSuccessEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'success',
- title,
- message,
-}))
-const createWarningEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'warning',
- title,
- message,
-}))
-const interactionReplyMock = jest.fn()
-const smartShuffleQueueMock = jest.fn()
-const rescueQueueMock = jest.fn()
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('@lucky/shared/utils', () => ({
- debugLog: (...args: unknown[]) => debugLogMock(...args),
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-jest.mock('../../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
-}))
-
-jest.mock('./queueEmbed', () => ({
- createQueueEmbed: (...args: unknown[]) => createQueueEmbedMock(...args),
- createQueueErrorEmbed: (...args: unknown[]) =>
- createQueueErrorEmbedMock(...args),
-}))
-
-jest.mock('../../../../utils/general/embeds', () => ({
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
- createWarningEmbed: (...args: unknown[]) => createWarningEmbedMock(...args),
-}))
-
-jest.mock('../../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../../utils/music/queueManipulation', () => ({
- smartShuffleQueue: (...args: unknown[]) => smartShuffleQueueMock(...args),
- rescueQueue: (...args: unknown[]) => rescueQueueMock(...args),
-}))
-
-jest.mock('../../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(action: string | null) {
- return {
- guildId: 'guild-1',
- options: {
- getString: jest.fn(() => action),
- },
- deferReply: jest.fn().mockResolvedValue(undefined),
- editReply: jest.fn().mockResolvedValue(undefined),
- } as any
-}
-
-function createClient() {
- return {} as any
-}
-
-describe('queue command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- smartShuffleQueueMock.mockResolvedValue(true)
- rescueQueueMock.mockResolvedValue({
- removedTracks: 1,
- keptTracks: 4,
- addedTracks: 2,
- })
- createQueueEmbedMock.mockResolvedValue({
- embed: { id: 'queue-embed' },
- components: [],
- })
- createQueueErrorEmbedMock.mockReturnValue({ id: 'queue-error' })
- })
-
- it('returns when guild validation fails', async () => {
- requireGuildMock.mockResolvedValue(false)
- const queue = { tracks: { size: 0 } }
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await queueCommand.execute({
- client: createClient(),
- interaction: createInteraction('show'),
- } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('warns when smartshuffle is requested with short queue', async () => {
- const queue = { tracks: { size: 1 } }
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await queueCommand.execute({
- client: createClient(),
- interaction: createInteraction('smartshuffle'),
- } as any)
-
- expect(createWarningEmbedMock).toHaveBeenCalledWith(
- 'Queue too short',
- 'Need at least 2 queued tracks for smart shuffle.',
- )
- })
-
- it('runs smartshuffle and returns success response', async () => {
- const queue = { tracks: { size: 3 } }
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await queueCommand.execute({
- client: createClient(),
- interaction: createInteraction('smartshuffle'),
- } as any)
-
- expect(smartShuffleQueueMock).toHaveBeenCalledWith(queue)
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Smart shuffle complete',
- 'Queue reordered with requester fairness and momentum.',
- )
- })
-
- it('runs rescue and returns counts', async () => {
- const queue = { tracks: { size: 3 } }
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await queueCommand.execute({
- client: createClient(),
- interaction: createInteraction('rescue'),
- } as any)
-
- expect(rescueQueueMock).toHaveBeenCalledWith(queue, {
- probeResolvable: true,
- })
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Queue rescue complete',
- expect.stringContaining('Removed 1 broken track(s)'),
- )
- })
-
- it('shows queue embed for default action', async () => {
- const queue = { tracks: { size: 3 } }
- resolveGuildQueueMock.mockReturnValue({ queue })
- const interaction = createInteraction('show')
-
- await queueCommand.execute({
- client: createClient(),
- interaction,
- } as any)
-
- expect(createQueueEmbedMock).toHaveBeenCalledWith(queue)
- expect(debugLogMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({ content: expect.objectContaining({ embeds: [{ id: 'queue-embed' }] }) }),
- )
- })
-
- it('returns queue error embed when execution throws', async () => {
- const queue = { tracks: { size: 3 } }
- resolveGuildQueueMock.mockReturnValue({ queue })
- createQueueEmbedMock.mockRejectedValue(new Error('boom'))
- const interaction = createInteraction('show')
-
- await queueCommand.execute({
- client: createClient(),
- interaction,
- } as any)
-
- expect(errorLogMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: [expect.objectContaining({ id: 'queue-error' })],
- }),
- }),
- )
- })
-
- it('logs error and replies with error embed when smartShuffle throws', async () => {
- const queue = { tracks: { size: 3 } }
- resolveGuildQueueMock.mockReturnValue({ queue })
- smartShuffleQueueMock.mockRejectedValue(new Error('shuffle failed'))
- const interaction = createInteraction('smartshuffle')
-
- await queueCommand.execute({
- client: createClient(),
- interaction,
- } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Error in queue command' }),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: [expect.objectContaining({ id: 'queue-error' })],
- }),
- }),
- )
- })
-
- it('logs error and replies with error embed when rescueQueue throws', async () => {
- const queue = { tracks: { size: 3 } }
- resolveGuildQueueMock.mockReturnValue({ queue })
- rescueQueueMock.mockRejectedValue(new Error('rescue failed'))
- const interaction = createInteraction('rescue')
-
- await queueCommand.execute({
- client: createClient(),
- interaction,
- } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Error in queue command' }),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: [expect.objectContaining({ id: 'queue-error' })],
- }),
- }),
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/queueResolverWiring.spec.ts b/packages/bot/src/functions/music/commands/queueResolverWiring.spec.ts
deleted file mode 100644
index 9a49411f4..000000000
--- a/packages/bot/src/functions/music/commands/queueResolverWiring.spec.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import clearCommand from './clear'
-import leaveCommand from './leave'
-import lyricsCommand from './lyrics'
-import moveCommand from './move'
-import pauseCommand from './pause'
-import removeCommand from './remove'
-import repeatCommand from './repeat'
-import shuffleCommand from './shuffle'
-import skipCommand from './skip'
-import songinfoCommand from './songinfo'
-import stopCommand from './stop'
-import volumeCommand from './volume'
-import type { CommandExecuteParams } from '../../../types/CommandData'
-
-const requireGuildMock = jest.fn()
-const requireDJRoleMock = jest.fn()
-const requireVoiceChannelMock = jest.fn()
-const requireQueueMock = jest.fn()
-const requireCurrentTrackMock = jest.fn()
-const requireIsPlayingMock = jest.fn()
-const resolveGuildQueueMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const featureToggleIsEnabledMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
- requireVoiceChannel: (...args: unknown[]) =>
- requireVoiceChannelMock(...args),
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireCurrentTrack: (...args: unknown[]) =>
- requireCurrentTrackMock(...args),
- requireIsPlaying: (...args: unknown[]) => requireIsPlayingMock(...args),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('@lucky/shared/services', () => ({
- featureToggleService: {
- isEnabled: (...args: unknown[]) => featureToggleIsEnabledMock(...args),
- },
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createErrorEmbed: jest.fn(() => ({})),
- createSuccessEmbed: jest.fn(() => ({})),
- createWarningEmbed: jest.fn(() => ({})),
- musicEmbed: jest.fn(() => ({
- setThumbnail: jest.fn(),
- })),
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- debugLog: jest.fn(),
- errorLog: jest.fn(),
- infoLog: jest.fn(),
-}))
-
-type CommandLike = {
- execute: (_params: unknown) => Promise
-}
-
-type QueueCase = {
- name: string
- command: CommandLike
-}
-
-type CommandInteraction = CommandExecuteParams['interaction']
-type CommandClient = CommandExecuteParams['client']
-type InteractionStub = Pick
-
-const queueValidationCases: QueueCase[] = [
- { name: 'clear', command: clearCommand },
- { name: 'leave', command: leaveCommand },
- { name: 'move', command: moveCommand },
- { name: 'pause', command: pauseCommand },
- { name: 'remove', command: removeCommand },
- { name: 'repeat', command: repeatCommand },
- { name: 'shuffle', command: shuffleCommand },
- { name: 'skip', command: skipCommand },
- { name: 'songinfo', command: songinfoCommand },
- { name: 'stop', command: stopCommand },
- { name: 'volume', command: volumeCommand },
-]
-
-function createClient(): CommandClient {
- return {
- player: {},
- } as unknown as CommandClient
-}
-
-function createInteraction(): InteractionStub {
- return {
- guildId: 'guild-1',
- user: {
- id: 'user-1',
- },
- options: {
- getString: jest.fn((name: string) => {
- if (name === 'mode') return 'off'
- if (name === 'song') return null
- return null
- }),
- getInteger: jest.fn(() => null),
- },
- }
-}
-
-function executeWithParams(
- command: CommandLike,
- client: CommandClient,
- interaction: InteractionStub,
-) {
- return command.execute({
- client,
- interaction: interaction as unknown as CommandInteraction,
- })
-}
-
-describe('music command resolver wiring', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- requireVoiceChannelMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(false)
- requireCurrentTrackMock.mockResolvedValue(true)
- requireIsPlayingMock.mockResolvedValue(true)
- requireDJRoleMock.mockResolvedValue(true)
- featureToggleIsEnabledMock.mockResolvedValue(true)
- resolveGuildQueueMock.mockReturnValue({
- queue: { id: 'queue-1' },
- source: 'cache.guild',
- diagnostics: {
- guildId: 'guild-1',
- cacheSize: 1,
- cacheSampleKeys: ['guild-1'],
- },
- })
- })
-
- for (const testCase of queueValidationCases) {
- it(`uses resolveGuildQueue in ${testCase.name}`, async () => {
- const client = createClient()
- const interaction = createInteraction()
-
- await executeWithParams(testCase.command, client, interaction)
-
- expect(resolveGuildQueueMock).toHaveBeenCalledWith(client, 'guild-1')
- expect(requireQueueMock).toHaveBeenCalledWith(
- expect.objectContaining({ id: 'queue-1' }),
- interaction,
- )
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
- }
-
- it(
- 'calls resolveGuildQueue before requireCurrentTrack when lyrics has no song',
- async () => {
- const client = createClient()
- const interaction = createInteraction()
- requireCurrentTrackMock.mockResolvedValue(false)
-
- await executeWithParams(lyricsCommand, client, interaction)
-
- expect(featureToggleIsEnabledMock).toHaveBeenCalledWith(
- 'LYRICS',
- expect.objectContaining({
- guildId: 'guild-1',
- userId: 'user-1',
- }),
- )
- expect(resolveGuildQueueMock).toHaveBeenCalledWith(client, 'guild-1')
- expect(requireCurrentTrackMock).toHaveBeenCalledWith(
- expect.objectContaining({ id: 'queue-1' }),
- interaction,
- )
- expect(interactionReplyMock).not.toHaveBeenCalled()
- },
- )
-
- it('returns early in lyrics when guild id is missing', async () => {
- const client = createClient()
- const interaction = {
- ...createInteraction(),
- guildId: null,
- }
-
- await executeWithParams(lyricsCommand, client, interaction)
-
- expect(resolveGuildQueueMock).not.toHaveBeenCalled()
- expect(requireCurrentTrackMock).not.toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: expect.any(Array),
- ephemeral: true,
- }),
- }),
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/recommendation/handlers/feedbackHandler.spec.ts b/packages/bot/src/functions/music/commands/recommendation/handlers/feedbackHandler.spec.ts
deleted file mode 100644
index b9d7cc0f9..000000000
--- a/packages/bot/src/functions/music/commands/recommendation/handlers/feedbackHandler.spec.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import { handleFeedback } from './feedbackHandler'
-
-const interactionReplyMock = jest.fn()
-const createErrorEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'error',
- title,
- message,
-}))
-const createWarningEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'warning',
- title,
- message,
-}))
-const createSuccessEmbedMock = jest.fn((title: string, message: string) => ({
- type: 'success',
- title,
- message,
-}))
-const buildTrackKeyMock = jest.fn()
-const setFeedbackMock = jest.fn()
-const resolveGuildQueueMock = jest.fn()
-type FeedbackInteraction = Parameters[0]
-type FeedbackClient = Parameters[1]
-
-jest.mock('../../../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../../../utils/general/embeds', () => ({
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
- createWarningEmbed: (...args: unknown[]) => createWarningEmbedMock(...args),
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
-}))
-
-jest.mock('../../../../../services/musicRecommendation/feedbackService', () => ({
- recommendationFeedbackService: {
- buildTrackKey: (...args: unknown[]) => buildTrackKeyMock(...args),
- setFeedback: (...args: unknown[]) => setFeedbackMock(...args),
- },
-}))
-
-jest.mock('../../../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(guildId: string | null, trackUrl?: string | null) {
- return {
- guildId,
- user: { id: 'user-1' },
- options: {
- getString: jest.fn((name: string, required?: boolean) => {
- if (name === 'feedback' && required) return 'like'
- if (name === 'track_url') return trackUrl ?? null
- return null
- }),
- },
- } as unknown as FeedbackInteraction
-}
-
-function createClient() {
- return {
- player: {},
- } as unknown as FeedbackClient
-}
-
-describe('handleFeedback', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- buildTrackKeyMock.mockImplementation(
- (a: string, b: string) => `${a}::${b}`,
- )
- setFeedbackMock.mockResolvedValue(undefined)
- resolveGuildQueueMock.mockReturnValue({
- queue: null,
- })
- })
-
- it('rejects execution outside guilds', async () => {
- await handleFeedback(createInteraction(null), createClient())
-
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- 'This command can only be used in a server!',
- )
- expect(setFeedbackMock).not.toHaveBeenCalled()
- })
-
- it('warns when there is no current track and no track url', async () => {
- await handleFeedback(createInteraction('guild-1'), createClient())
-
- expect(createWarningEmbedMock).toHaveBeenCalledWith(
- 'No Track',
- 'No current track found. Provide `track_url` to leave feedback.',
- )
- expect(setFeedbackMock).not.toHaveBeenCalled()
- })
-
- it('stores feedback using current track identity', async () => {
- const currentTrack = {
- title: 'Song A',
- author: 'Artist A',
- url: 'https://example.com/a',
- }
- resolveGuildQueueMock.mockReturnValue({
- queue: { currentTrack },
- })
-
- await handleFeedback(
- createInteraction('guild-1', 'https://example.com/a'),
- createClient(),
- )
-
- expect(buildTrackKeyMock).toHaveBeenCalledWith('Song A', 'Artist A')
- expect(setFeedbackMock).toHaveBeenCalledWith(
- 'guild-1',
- 'user-1',
- 'Song A::Artist A',
- 'like',
- )
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Feedback saved',
- 'Stored **like** feedback for this recommendation profile.',
- )
- })
-
- it('stores feedback using explicit track url fallback', async () => {
- const currentTrack = {
- title: 'Song A',
- author: 'Artist A',
- url: 'https://example.com/a',
- }
- resolveGuildQueueMock.mockReturnValue({
- queue: { currentTrack },
- })
-
- await handleFeedback(
- createInteraction('guild-1', 'https://example.com/other'),
- createClient(),
- )
-
- expect(buildTrackKeyMock).toHaveBeenCalledWith(
- 'https://example.com/other',
- 'url',
- )
- expect(setFeedbackMock).toHaveBeenCalled()
- })
-
- it('passes resolver miss queue to warning branch', async () => {
- await handleFeedback(createInteraction('guild-1'), createClient())
-
- expect(resolveGuildQueueMock).toHaveBeenCalledWith(
- expect.anything(),
- 'guild-1',
- )
- expect(createWarningEmbedMock).toHaveBeenCalledWith(
- 'No Track',
- 'No current track found. Provide `track_url` to leave feedback.',
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/recommendation/index.spec.ts b/packages/bot/src/functions/music/commands/recommendation/index.spec.ts
deleted file mode 100644
index 138463b4d..000000000
--- a/packages/bot/src/functions/music/commands/recommendation/index.spec.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import recommendationCommand from './index'
-
-const requireGuildMock = jest.fn()
-const handleShowSettingsMock = jest.fn()
-const handleUpdateSettingsMock = jest.fn()
-const handleApplyPresetMock = jest.fn()
-const handleResetSettingsMock = jest.fn()
-const handleFeedbackMock = jest.fn()
-const interactionReplyMock = jest.fn()
-
-jest.mock('../../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
-}))
-
-jest.mock('./handlers', () => ({
- handleShowSettings: (...args: unknown[]) => handleShowSettingsMock(...args),
- handleUpdateSettings: (...args: unknown[]) => handleUpdateSettingsMock(...args),
- handleApplyPreset: (...args: unknown[]) => handleApplyPresetMock(...args),
- handleResetSettings: (...args: unknown[]) => handleResetSettingsMock(...args),
- handleFeedback: (...args: unknown[]) => handleFeedbackMock(...args),
-}))
-
-function createInteraction(subcommand: string) {
- return {
- options: {
- getSubcommand: jest.fn(() => subcommand),
- },
- reply: jest.fn(),
- } as any
-}
-
-describe('recommendation command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- })
-
- it('returns early when guild validation fails', async () => {
- requireGuildMock.mockResolvedValue(false)
-
- await recommendationCommand.execute({
- client: {},
- interaction: createInteraction('show'),
- } as any)
-
- expect(handleShowSettingsMock).not.toHaveBeenCalled()
- })
-
- it('routes show/update/preset/reset subcommands to handlers', async () => {
- await recommendationCommand.execute({
- client: {},
- interaction: createInteraction('show'),
- } as any)
- await recommendationCommand.execute({
- client: {},
- interaction: createInteraction('update'),
- } as any)
- await recommendationCommand.execute({
- client: {},
- interaction: createInteraction('preset'),
- } as any)
- await recommendationCommand.execute({
- client: {},
- interaction: createInteraction('reset'),
- } as any)
-
- expect(handleShowSettingsMock).toHaveBeenCalledTimes(1)
- expect(handleUpdateSettingsMock).toHaveBeenCalledTimes(1)
- expect(handleApplyPresetMock).toHaveBeenCalledTimes(1)
- expect(handleResetSettingsMock).toHaveBeenCalledTimes(1)
- })
-
- it('routes feedback subcommand with interaction and client', async () => {
- const client = { marker: 'client' }
- const interaction = createInteraction('feedback')
-
- await recommendationCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(handleFeedbackMock).toHaveBeenCalledWith(interaction, client)
- })
-
- it('replies for unknown subcommand', async () => {
- const interaction = createInteraction('unknown')
-
- await recommendationCommand.execute({
- client: {},
- interaction,
- } as any)
-
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- content: 'Unknown subcommand.',
- ephemeral: true,
- }),
- }),
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/remove.spec.ts b/packages/bot/src/functions/music/commands/remove.spec.ts
deleted file mode 100644
index 509b8038c..000000000
--- a/packages/bot/src/functions/music/commands/remove.spec.ts
+++ /dev/null
@@ -1,282 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import removeCommand from './remove'
-
-const requireGuildMock = jest.fn()
-const requireQueueMock = jest.fn()
-const requireCurrentTrackMock = jest.fn()
-const requireVoiceChannelMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
-}))
-const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
-}))
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireCurrentTrack: (...args: unknown[]) =>
- requireCurrentTrackMock(...args),
- requireVoiceChannel: (...args: unknown[]) =>
- requireVoiceChannelMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(position: number, guildId = 'guild-1') {
- return {
- guildId,
- user: { id: 'user-1' },
- options: {
- getInteger: jest.fn(() => position),
- },
- } as any
-}
-
-function createTrack(position: number) {
- return {
- id: `track-${position}`,
- title: `Song ${position}`,
- author: `Artist ${position}`,
- url: `https://example.com/track-${position}`,
- }
-}
-
-function createQueue(trackCount = 10) {
- const tracks = Array.from({ length: trackCount }, (_, i) =>
- createTrack(i + 1),
- )
- return {
- guild: { id: 'guild-1' },
- tracks: {
- size: trackCount,
- toArray: jest.fn().mockReturnValue([...tracks]),
- remove: jest.fn(),
- },
- } as any
-}
-
-function createClient() {
- return {
- player: {
- nodes: {
- get: jest.fn(),
- },
- },
- } as any
-}
-
-describe('remove command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- requireCurrentTrackMock.mockResolvedValue(true)
- requireVoiceChannelMock.mockResolvedValue(true)
- })
-
- it('removes track at specified position', async () => {
- const queue = createQueue(5)
- const client = createClient()
- const interaction = createInteraction(2)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Song removed',
- expect.stringContaining('Song 2'),
- )
- })
-
- it('removes first track from queue', async () => {
- const queue = createQueue(5)
- const client = createClient()
- const interaction = createInteraction(1)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Song removed',
- expect.stringContaining('Song 1'),
- )
- })
-
- it('removes last track from queue', async () => {
- const queue = createQueue(5)
- const client = createClient()
- const interaction = createInteraction(5)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Song removed',
- expect.stringContaining('Song 5'),
- )
- })
-
- it('shows error when position is out of range (too high)', async () => {
- const queue = createQueue(5)
- const client = createClient()
- const interaction = createInteraction(10)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).not.toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- 'Invalid position!',
- )
- })
-
- it('shows error when position is negative', async () => {
- const queue = createQueue(5)
- const client = createClient()
- const interaction = createInteraction(-1)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).not.toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- 'Invalid position!',
- )
- })
-
- it('shows error when queue is empty', async () => {
- const queue = createQueue(0)
- const client = createClient()
- const interaction = createInteraction(1)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).not.toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- 'The queue is empty!',
- )
- })
-
- it('returns early when guild validation fails', async () => {
- requireGuildMock.mockResolvedValue(false)
- const queue = createQueue(5)
- const client = createClient()
- const interaction = createInteraction(2)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when voice channel validation fails', async () => {
- requireVoiceChannelMock.mockResolvedValue(false)
- const queue = createQueue(5)
- const client = createClient()
- const interaction = createInteraction(2)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when queue validation fails', async () => {
- requireQueueMock.mockResolvedValue(false)
- const queue = createQueue(5)
- const client = createClient()
- const interaction = createInteraction(2)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when current track validation fails', async () => {
- requireCurrentTrackMock.mockResolvedValue(false)
- const queue = createQueue(5)
- const client = createClient()
- const interaction = createInteraction(2)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('removes from large queue', async () => {
- const queue = createQueue(100)
- const client = createClient()
- const interaction = createInteraction(50)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await removeCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.tracks.remove).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Song removed',
- expect.stringContaining('Song 50'),
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/replay.spec.ts b/packages/bot/src/functions/music/commands/replay.spec.ts
deleted file mode 100644
index f9c7b064f..000000000
--- a/packages/bot/src/functions/music/commands/replay.spec.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import replayCommand from './replay'
-
-const requireQueueMock = jest.fn()
-const requireDJRoleMock = jest.fn()
-const requireCurrentTrackMock = jest.fn()
-const requireIsPlayingMock = jest.fn()
-const requireVoiceChannelMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc }))
-const buildCommandTrackEmbedMock = jest.fn(() => ({}))
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireCurrentTrack: (...args: unknown[]) => requireCurrentTrackMock(...args),
- requireIsPlaying: (...args: unknown[]) => requireIsPlayingMock(...args),
- requireVoiceChannel: (...args: unknown[]) => requireVoiceChannelMock(...args),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args)
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/general/responseEmbeds', () => ({
- buildCommandTrackEmbed: (...args: unknown[]) => buildCommandTrackEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(guildId = 'guild-1') {
- return {
- guildId,
- user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') },
- } as any
-}
-
-function createQueue(currentTrack: unknown = null) {
- return {
- node: {
- seek: jest.fn(),
- },
- currentTrack,
- } as any
-}
-
-function createClient() {
- return {} as any
-}
-
-describe('replay command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireVoiceChannelMock.mockResolvedValue(true)
- requireDJRoleMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- requireCurrentTrackMock.mockResolvedValue(true)
- requireIsPlayingMock.mockResolvedValue(true)
- buildCommandTrackEmbedMock.mockReturnValue({})
- })
-
- it('returns early when voice channel validation fails', async () => {
- requireVoiceChannelMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await replayCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.seek).not.toHaveBeenCalled()
- })
-
- it('returns early when queue validation fails', async () => {
- requireQueueMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await replayCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.seek).not.toHaveBeenCalled()
- })
-
- it('seeks to beginning with current track', async () => {
- const currentTrack = { id: 'track-1', title: 'Test Song' }
- const queue = createQueue(currentTrack)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await replayCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.seek).toHaveBeenCalledWith(0)
- expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(currentTrack, '🔄 Replayed', expect.any(Object))
- })
-
- it('seeks to beginning with no current track', async () => {
- const queue = createQueue(null)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await replayCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.seek).toHaveBeenCalledWith(0)
- expect(createSuccessEmbedMock).toHaveBeenCalledWith('🔄 Replayed', 'Track has been replayed from the beginning.')
- })
-})
diff --git a/packages/bot/src/functions/music/commands/seek.spec.ts b/packages/bot/src/functions/music/commands/seek.spec.ts
deleted file mode 100644
index 564a1452e..000000000
--- a/packages/bot/src/functions/music/commands/seek.spec.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import seekCommand from './seek'
-
-const requireQueueMock = jest.fn()
-const requireDJRoleMock = jest.fn()
-const requireCurrentTrackMock = jest.fn()
-const requireIsPlayingMock = jest.fn()
-const requireVoiceChannelMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc }))
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc }))
-const buildCommandTrackEmbedMock = jest.fn(() => ({}))
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireCurrentTrack: (...args: unknown[]) => requireCurrentTrackMock(...args),
- requireIsPlaying: (...args: unknown[]) => requireIsPlayingMock(...args),
- requireVoiceChannel: (...args: unknown[]) => requireVoiceChannelMock(...args),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args)
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/general/responseEmbeds', () => ({
- buildCommandTrackEmbed: (...args: unknown[]) => buildCommandTrackEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(guildId = 'guild-1', timeStr = '1:30') {
- return {
- guildId,
- user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') },
- options: {
- getString: jest.fn().mockReturnValue(timeStr),
- },
- } as any
-}
-
-function createQueue(currentTrack: unknown = null) {
- return {
- node: {
- seek: jest.fn(),
- },
- currentTrack,
- } as any
-}
-
-function createClient() {
- return {} as any
-}
-
-describe('seek command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireVoiceChannelMock.mockResolvedValue(true)
- requireDJRoleMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- requireCurrentTrackMock.mockResolvedValue(true)
- requireIsPlayingMock.mockResolvedValue(true)
- buildCommandTrackEmbedMock.mockReturnValue({})
- })
-
- it('returns early when voice channel validation fails', async () => {
- requireVoiceChannelMock.mockResolvedValue(false)
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await seekCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.seek).not.toHaveBeenCalled()
- })
-
- it('seeks to time in mm:ss format', async () => {
- const currentTrack = { id: 'track-1', title: 'Test Song', durationMS: 300000 }
- const queue = createQueue(currentTrack)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- const interaction = createInteraction('guild-1', '1:30')
- await seekCommand.execute({ client: createClient(), interaction } as any)
-
- expect(queue.node.seek).toHaveBeenCalledWith(90000)
- })
-
- it('seeks to time in ss format', async () => {
- const currentTrack = { id: 'track-1', title: 'Test Song', durationMS: 300000 }
- const queue = createQueue(currentTrack)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- const interaction = createInteraction('guild-1', '45')
- await seekCommand.execute({ client: createClient(), interaction } as any)
-
- expect(queue.node.seek).toHaveBeenCalledWith(45000)
- })
-
- it('shows error for invalid time format', async () => {
- const currentTrack = { id: 'track-1', title: 'Test Song', durationMS: 300000 }
- const queue = createQueue(currentTrack)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- const interaction = createInteraction('guild-1', 'invalid')
- await seekCommand.execute({ client: createClient(), interaction } as any)
-
- expect(queue.node.seek).not.toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Invalid time format', expect.any(String))
- })
-
- it('shows error when seek time exceeds track duration', async () => {
- const currentTrack = { id: 'track-1', title: 'Test Song', durationMS: 60000 }
- const queue = createQueue(currentTrack)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- const interaction = createInteraction('guild-1', '2:30')
- await seekCommand.execute({ client: createClient(), interaction } as any)
-
- expect(queue.node.seek).not.toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Time out of range', expect.any(String))
- })
-
- it('shows error when track does not support seeking', async () => {
- const currentTrack = { id: 'track-1', title: 'Live Stream', durationMS: null }
- const queue = createQueue(currentTrack)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- const interaction = createInteraction('guild-1', '1:30')
- await seekCommand.execute({ client: createClient(), interaction } as any)
-
- expect(queue.node.seek).not.toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Cannot seek', expect.any(String))
- })
-})
diff --git a/packages/bot/src/functions/music/commands/shuffle.spec.ts b/packages/bot/src/functions/music/commands/shuffle.spec.ts
deleted file mode 100644
index c56ca154a..000000000
--- a/packages/bot/src/functions/music/commands/shuffle.spec.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import shuffleCommand from './shuffle'
-
-const requireGuildMock = jest.fn()
-const requireVoiceChannelMock = jest.fn()
-const requireQueueMock = jest.fn()
-const requireCurrentTrackMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
-}))
-const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
-}))
-const resolveGuildQueueMock = jest.fn()
-const smartShuffleMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
- requireVoiceChannel: (...args: unknown[]) =>
- requireVoiceChannelMock(...args),
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireCurrentTrack: (...args: unknown[]) =>
- requireCurrentTrackMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-jest.mock('../../../utils/music/queue/smartShuffle', () => ({
- smartShuffle: (...args: unknown[]) => smartShuffleMock(...args),
-}))
-
-function makeTrack(id: string) {
- return {
- id,
- title: `Song ${id}`,
- author: 'Artist',
- duration: '3:00',
- source: 'youtube',
- requestedBy: { id: 'user-1' },
- }
-}
-
-function makeQueue(trackCount = 5) {
- const tracks = Array.from({ length: trackCount }, (_, i) =>
- makeTrack(`t${i + 1}`),
- )
- return {
- tracks: {
- size: trackCount,
- toArray: jest.fn().mockReturnValue([...tracks]),
- shuffle: jest.fn(),
- clear: jest.fn(),
- add: jest.fn(),
- },
- }
-}
-
-function makeInteraction(subcommand: string | null = 'random', guildId = 'g1') {
- return {
- guildId,
- options: { getSubcommand: jest.fn().mockReturnValue(subcommand) },
- }
-}
-
-function makeClient() {
- return {}
-}
-
-describe('shuffle command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireGuildMock.mockResolvedValue(true)
- requireVoiceChannelMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- requireCurrentTrackMock.mockResolvedValue(true)
- interactionReplyMock.mockResolvedValue(undefined)
- })
-
- it('stops if requireGuild fails', async () => {
- requireGuildMock.mockResolvedValue(false)
- const interaction = makeInteraction()
- await shuffleCommand.execute({
- client: makeClient() as never,
- interaction: interaction as never,
- })
- expect(resolveGuildQueueMock).not.toHaveBeenCalled()
- })
-
- it('stops if requireVoiceChannel fails', async () => {
- requireVoiceChannelMock.mockResolvedValue(false)
- const queue = makeQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
- const interaction = makeInteraction()
- await shuffleCommand.execute({
- client: makeClient() as never,
- interaction: interaction as never,
- })
- expect(requireQueueMock).not.toHaveBeenCalled()
- })
-
- it('stops if requireQueue fails', async () => {
- requireQueueMock.mockResolvedValue(false)
- const queue = makeQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
- const interaction = makeInteraction()
- await shuffleCommand.execute({
- client: makeClient() as never,
- interaction: interaction as never,
- })
- expect(requireCurrentTrackMock).not.toHaveBeenCalled()
- })
-
- it('stops if requireCurrentTrack fails', async () => {
- requireCurrentTrackMock.mockResolvedValue(false)
- const queue = makeQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
- const interaction = makeInteraction()
- await shuffleCommand.execute({
- client: makeClient() as never,
- interaction: interaction as never,
- })
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('replies with error when queue has fewer than 2 tracks', async () => {
- const queue = makeQueue(1)
- resolveGuildQueueMock.mockReturnValue({ queue })
- const interaction = makeInteraction()
- await shuffleCommand.execute({
- client: makeClient() as never,
- interaction: interaction as never,
- })
- expect(createErrorEmbedMock).toHaveBeenCalled()
- expect(queue.tracks.shuffle).not.toHaveBeenCalled()
- })
-
- it('calls queue.tracks.shuffle() for random subcommand', async () => {
- const queue = makeQueue(5)
- resolveGuildQueueMock.mockReturnValue({ queue })
- const interaction = makeInteraction('random')
- await shuffleCommand.execute({
- client: makeClient() as never,
- interaction: interaction as never,
- })
- expect(queue.tracks.shuffle).toHaveBeenCalled()
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Queue shuffled',
- expect.stringContaining('shuffled successfully'),
- )
- })
-
- it('defaults to random when subcommand is null', async () => {
- const queue = makeQueue(5)
- resolveGuildQueueMock.mockReturnValue({ queue })
- const interaction = makeInteraction(null)
- await shuffleCommand.execute({
- client: makeClient() as never,
- interaction: interaction as never,
- })
- expect(queue.tracks.shuffle).toHaveBeenCalled()
- })
-
- it('calls smartShuffle and replaces tracks for smart subcommand', async () => {
- const originalTracks = Array.from({ length: 4 }, (_, i) =>
- makeTrack(`t${i + 1}`),
- )
- const shuffledTracks = [...originalTracks].reverse()
- const queue = makeQueue(4)
- queue.tracks.toArray = jest.fn().mockReturnValue(originalTracks)
- smartShuffleMock.mockReturnValue(shuffledTracks)
- resolveGuildQueueMock.mockReturnValue({ queue })
- const interaction = makeInteraction('smart')
- await shuffleCommand.execute({
- client: makeClient() as never,
- interaction: interaction as never,
- })
- expect(smartShuffleMock).toHaveBeenCalledWith(
- originalTracks,
- expect.objectContaining({ streakLimit: expect.any(Number) }),
- )
- expect(queue.tracks.clear).toHaveBeenCalled()
- expect(queue.tracks.add).toHaveBeenCalledTimes(shuffledTracks.length)
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- 'Queue smart-shuffled',
- expect.stringContaining('smart-shuffled'),
- )
- })
-
- it('smart shuffle adds each shuffled track to the queue', async () => {
- const tracks = [makeTrack('a'), makeTrack('b'), makeTrack('c')]
- const queue = makeQueue(3)
- queue.tracks.toArray = jest.fn().mockReturnValue(tracks)
- smartShuffleMock.mockReturnValue(tracks)
- resolveGuildQueueMock.mockReturnValue({ queue })
- const interaction = makeInteraction('smart')
- await shuffleCommand.execute({
- client: makeClient() as never,
- interaction: interaction as never,
- })
- tracks.forEach((t) => expect(queue.tracks.add).toHaveBeenCalledWith(t))
- })
-})
diff --git a/packages/bot/src/functions/music/commands/skip.spec.ts b/packages/bot/src/functions/music/commands/skip.spec.ts
deleted file mode 100644
index 2cd71a92b..000000000
--- a/packages/bot/src/functions/music/commands/skip.spec.ts
+++ /dev/null
@@ -1,307 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import skipCommand from './skip'
-
-const requireGuildMock = jest.fn()
-const requireDJRoleMock = jest.fn()
-const requireQueueMock = jest.fn()
-const requireCurrentTrackMock = jest.fn()
-const requireIsPlayingMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
-}))
-const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({
- title,
- description: desc,
-}))
-const buildCommandTrackEmbedMock = jest.fn(() => ({}))
-const debugLogMock = jest.fn()
-const errorLogMock = jest.fn()
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireGuild: (...args: unknown[]) => requireGuildMock(...args),
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireCurrentTrack: (...args: unknown[]) =>
- requireCurrentTrackMock(...args),
- requireIsPlaying: (...args: unknown[]) => requireIsPlayingMock(...args),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args)
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- debugLog: (...args: unknown[]) => debugLogMock(...args),
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-jest.mock('../../../utils/general/responseEmbeds', () => ({
- buildCommandTrackEmbed: (...args: unknown[]) => buildCommandTrackEmbedMock(...args),
-}))
-
-function createInteraction(guildId = 'guild-1') {
- return {
- guildId,
- user: { id: 'user-1' },
- } as any
-}
-
-function createQueue() {
- return {
- guild: { id: 'guild-1' },
- isPlaying: jest.fn().mockReturnValue(true),
- node: {
- skip: jest.fn(),
- play: jest.fn(),
- },
- tracks: {
- size: 5,
- },
- } as any
-}
-
-function createClient() {
- return {
- player: {
- nodes: {
- get: jest.fn(),
- },
- },
- } as any
-}
-
-describe('skip command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- jest.useFakeTimers()
- requireGuildMock.mockResolvedValue(true)
- requireDJRoleMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- requireCurrentTrackMock.mockResolvedValue(true)
- requireIsPlayingMock.mockResolvedValue(true)
- buildCommandTrackEmbedMock.mockReturnValue({})
- })
-
- afterEach(() => {
- jest.useRealTimers()
- })
-
- it('skips the current song and sends success response', async () => {
- const queue = createQueue()
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skipCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.node.skip).toHaveBeenCalled()
- expect(debugLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Skipped current song'),
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- interaction,
- }),
- )
- expect(createSuccessEmbedMock).toHaveBeenCalledWith(
- '⏭️ Song skipped',
- 'The current song has been skipped.',
- )
- })
-
- it('auto-plays after skip when queue has remaining tracks', async () => {
- const queue = createQueue()
- let isPlayingAfterSkip = true
- queue.isPlaying.mockImplementation(() => isPlayingAfterSkip)
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- queue.node.skip.mockImplementation(() => {
- isPlayingAfterSkip = false
- })
-
- const executePromise = skipCommand.execute({
- client,
- interaction,
- } as any)
-
- await executePromise
- jest.advanceTimersByTime(500)
- await Promise.resolve()
-
- expect(queue.node.play).toHaveBeenCalled()
- })
-
- it('returns early when guild validation fails', async () => {
- requireGuildMock.mockResolvedValue(false)
- const queue = createQueue()
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skipCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.node.skip).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when queue validation fails', async () => {
- requireQueueMock.mockResolvedValue(false)
- const queue = createQueue()
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skipCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.node.skip).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when current track validation fails', async () => {
- requireCurrentTrackMock.mockResolvedValue(false)
- const queue = createQueue()
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skipCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.node.skip).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when is playing validation fails', async () => {
- requireIsPlayingMock.mockResolvedValue(false)
- const queue = createQueue()
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skipCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.node.skip).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('shows error when queue is not playing', async () => {
- requireIsPlayingMock.mockResolvedValue(true)
- const queue = createQueue()
- queue.isPlaying.mockReturnValue(false)
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skipCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(queue.node.skip).not.toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- "🤔 There's no music playing at the moment.",
- )
- })
-
- it('handles error thrown by discord-player gracefully', async () => {
- const queue = createQueue()
- const skipError = new Error('Discord player error')
- queue.node.skip.mockImplementation(() => {
- throw skipError
- })
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skipCommand.execute({
- client,
- interaction,
- } as any)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Error in skip command:',
- error: skipError,
- }),
- )
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Error',
- 'An error occurred while trying to skip the song.',
- )
- })
-
- it('does not auto-play when queue becomes empty after skip', async () => {
- const queue = createQueue()
- queue.isPlaying.mockReturnValue(false)
- queue.tracks.size = 0
- const client = createClient()
- const interaction = createInteraction()
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skipCommand.execute({
- client,
- interaction,
- } as any)
-
- jest.advanceTimersByTime(500)
- await Promise.resolve()
-
- expect(queue.node.play).not.toHaveBeenCalled()
- })
-
- it('shows rich track embed for next track after skip', async () => {
- const nextTrack = { title: 'Next Song', author: 'Artist', url: 'http://x', duration: '3:00' }
- const queue = {
- ...createQueue(),
- currentTrack: nextTrack,
- }
- const client = createClient()
- const interaction = {
- ...createInteraction(),
- user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') },
- }
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skipCommand.execute({ client, interaction } as any)
-
- expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(
- nextTrack,
- '⏭️ Song skipped - Now playing',
- expect.any(Object),
- )
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-})
diff --git a/packages/bot/src/functions/music/commands/skipto.spec.ts b/packages/bot/src/functions/music/commands/skipto.spec.ts
deleted file mode 100644
index c5afea0c0..000000000
--- a/packages/bot/src/functions/music/commands/skipto.spec.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import skiptoCommand from './skipto'
-
-const requireQueueMock = jest.fn()
-const requireDJRoleMock = jest.fn()
-const requireVoiceChannelMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc }))
-const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc }))
-const buildCommandTrackEmbedMock = jest.fn(() => ({}))
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireVoiceChannel: (...args: unknown[]) => requireVoiceChannelMock(...args),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args)
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/general/responseEmbeds', () => ({
- buildCommandTrackEmbed: (...args: unknown[]) => buildCommandTrackEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(guildId = 'guild-1', position = 2) {
- return {
- guildId,
- user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') },
- options: {
- getInteger: jest.fn().mockReturnValue(position),
- },
- } as any
-}
-
-function createQueue(tracks: unknown[] = []) {
- return {
- node: {
- skipTo: jest.fn(),
- },
- tracks: {
- size: tracks.length,
- toArray: jest.fn().mockReturnValue(tracks),
- },
- } as any
-}
-
-function createClient() {
- return {} as any
-}
-
-describe('skipto command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireVoiceChannelMock.mockResolvedValue(true)
- requireDJRoleMock.mockResolvedValue(true)
- requireQueueMock.mockResolvedValue(true)
- buildCommandTrackEmbedMock.mockReturnValue({})
- })
-
- it('returns early when voice channel validation fails', async () => {
- requireVoiceChannelMock.mockResolvedValue(false)
- const queue = createQueue([{ id: 'track-1' }])
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await skiptoCommand.execute({ client: createClient(), interaction: createInteraction() } as any)
-
- expect(queue.node.skipTo).not.toHaveBeenCalled()
- })
-
- it('skips to specified position', async () => {
- const tracks = [
- { id: 'track-1', title: 'Song 1' },
- { id: 'track-2', title: 'Song 2' },
- { id: 'track-3', title: 'Song 3' },
- ]
- const queue = createQueue(tracks)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- const interaction = createInteraction('guild-1', 2)
- await skiptoCommand.execute({ client: createClient(), interaction } as any)
-
- expect(queue.node.skipTo).toHaveBeenCalledWith(1)
- expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(tracks[1], '⏭️ Now playing (position 2)', expect.any(Object))
- })
-
- it('shows error when position exceeds queue size', async () => {
- const tracks = [
- { id: 'track-1', title: 'Song 1' },
- { id: 'track-2', title: 'Song 2' },
- ]
- const queue = createQueue(tracks)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- const interaction = createInteraction('guild-1', 5)
- await skiptoCommand.execute({ client: createClient(), interaction } as any)
-
- expect(queue.node.skipTo).not.toHaveBeenCalled()
- expect(createErrorEmbedMock).toHaveBeenCalledWith('Invalid position', expect.any(String))
- })
-
- it('accepts position 1 for first track in queue', async () => {
- const tracks = [
- { id: 'track-1', title: 'Song 1' },
- { id: 'track-2', title: 'Song 2' },
- ]
- const queue = createQueue(tracks)
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- const interaction = createInteraction('guild-1', 1)
- await skiptoCommand.execute({ client: createClient(), interaction } as any)
-
- expect(queue.node.skipTo).toHaveBeenCalledWith(0)
- })
-})
diff --git a/packages/bot/src/functions/music/commands/songinfo.spec.ts b/packages/bot/src/functions/music/commands/songinfo.spec.ts
deleted file mode 100644
index f062d286b..000000000
--- a/packages/bot/src/functions/music/commands/songinfo.spec.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import songinfoCommand from './songinfo'
-
-const requireQueueMock = jest.fn()
-const requireCurrentTrackMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const buildTrackEmbedMock = jest.fn(() => ({}))
-const trackToDataMock = jest.fn((track: unknown) => track)
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireCurrentTrack: (...args: unknown[]) => requireCurrentTrackMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/responseEmbeds', () => ({
- buildTrackEmbed: (...args: unknown[]) => buildTrackEmbedMock(...args),
- trackToData: (...args: unknown[]) => trackToDataMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-function createInteraction(guildId = 'guild-1') {
- return {
- guildId,
- user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') },
- } as any
-}
-
-describe('songinfo command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- requireQueueMock.mockResolvedValue(true)
- requireCurrentTrackMock.mockResolvedValue(true)
- })
-
- it('returns early when queue validation fails', async () => {
- requireQueueMock.mockResolvedValue(false)
- const queue = { currentTrack: null }
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await songinfoCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('returns early when current track validation fails', async () => {
- requireCurrentTrackMock.mockResolvedValue(false)
- const queue = { currentTrack: null }
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await songinfoCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(interactionReplyMock).not.toHaveBeenCalled()
- })
-
- it('shows rich track embed for current song', async () => {
- const track = { title: 'Test Song', author: 'Artist', url: 'http://x', duration: '3:00' }
- const queue = { currentTrack: track }
- resolveGuildQueueMock.mockReturnValue({ queue })
-
- await songinfoCommand.execute({ client: {} as any, interaction: createInteraction() } as any)
-
- expect(trackToDataMock).toHaveBeenCalledWith(track)
- expect(buildTrackEmbedMock).toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({ embeds: expect.any(Array) }),
- }),
- )
- })
-})
diff --git a/packages/bot/src/functions/music/commands/stop.spec.ts b/packages/bot/src/functions/music/commands/stop.spec.ts
deleted file mode 100644
index 823b0ab1e..000000000
--- a/packages/bot/src/functions/music/commands/stop.spec.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import stopCommand from './stop'
-
-const requireQueueMock = jest.fn()
-const requireDJRoleMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createSuccessEmbedMock = jest.fn()
-const resolveGuildQueueMock = jest.fn()
-const markIntentionalStopMock = jest.fn()
-const deleteSnapshotMock = jest.fn()
-
-jest.mock('../../../utils/music/sessionSnapshots', () => ({
- musicSessionSnapshotService: {
- deleteSnapshot: (...args: unknown[]) => deleteSnapshotMock(...args),
- },
-}))
-
-jest.mock('../../../utils/command/commandValidations', () => ({
- requireQueue: (...args: unknown[]) => requireQueueMock(...args),
- requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args),
-}))
-
-jest.mock('../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('../../../utils/general/embeds', () => ({
- createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args),
-}))
-
-jest.mock('../../../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-jest.mock('../../../utils/music/watchdog', () => ({
- musicWatchdogService: {
- markIntentionalStop: (...args: unknown[]) =>
- markIntentionalStopMock(...args),
- },
-}))
-
-function createInteraction(guildId = 'guild-1') {
- return { guildId } as any
-}
-
-function createQueue(guildId = 'guild-1') {
- return {
- guild: { id: guildId },
- node: { stop: jest.fn() },
- clear: jest.fn(),
- delete: jest.fn(),
- }
-}
-
-describe('stop command', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- interactionReplyMock.mockResolvedValue(undefined)
- createSuccessEmbedMock.mockReturnValue({ title: 'Playback stopped' })
- requireDJRoleMock.mockResolvedValue(true)
- deleteSnapshotMock.mockResolvedValue(undefined)
- })
-
- it('stops node, clears and deletes queue', async () => {
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
- requireQueueMock.mockResolvedValue(true)
-
- await stopCommand.execute({
- interaction: createInteraction(),
- client: {} as any,
- })
-
- expect(markIntentionalStopMock).toHaveBeenCalledWith('guild-1')
- expect(deleteSnapshotMock).toHaveBeenCalledWith('guild-1')
- expect(queue.node.stop).toHaveBeenCalled()
- expect(queue.clear).toHaveBeenCalled()
- expect(queue.delete).toHaveBeenCalled()
- })
-
- it('calls stop, clear, delete in order', async () => {
- const queue = createQueue()
- const callOrder: string[] = []
- markIntentionalStopMock.mockImplementation(() => callOrder.push('mark'))
- deleteSnapshotMock.mockImplementation(async () =>
- callOrder.push('deleteSnapshot'),
- )
- queue.node.stop.mockImplementation(() => callOrder.push('stop'))
- queue.clear.mockImplementation(() => callOrder.push('clear'))
- queue.delete.mockImplementation(() => callOrder.push('delete'))
- resolveGuildQueueMock.mockReturnValue({ queue })
- requireQueueMock.mockResolvedValue(true)
-
- await stopCommand.execute({
- interaction: createInteraction(),
- client: {} as any,
- })
-
- expect(callOrder).toEqual(['mark', 'deleteSnapshot', 'stop', 'clear', 'delete'])
- })
-
- it('replies with success embed after stopping', async () => {
- const queue = createQueue()
- resolveGuildQueueMock.mockReturnValue({ queue })
- requireQueueMock.mockResolvedValue(true)
- createSuccessEmbedMock.mockReturnValue({ title: 'Playback stopped' })
-
- await stopCommand.execute({
- interaction: createInteraction(),
- client: {} as any,
- })
-
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- content: expect.objectContaining({
- embeds: expect.any(Array),
- }),
- }),
- )
- })
-
- it('returns early when queue check fails', async () => {
- resolveGuildQueueMock.mockReturnValue({ queue: null })
- requireQueueMock.mockResolvedValue(false)
-
- await stopCommand.execute({
- interaction: createInteraction(),
- client: {} as any,
- })
-
- expect(markIntentionalStopMock).not.toHaveBeenCalled()
- expect(interactionReplyMock).not.toHaveBeenCalled()
- expect(deleteSnapshotMock).not.toHaveBeenCalled()
- })
-})
diff --git a/packages/bot/src/functions/music/handlers/play/handlePlay.spec.ts b/packages/bot/src/functions/music/handlers/play/handlePlay.spec.ts
deleted file mode 100644
index 63be38db6..000000000
--- a/packages/bot/src/functions/music/handlers/play/handlePlay.spec.ts
+++ /dev/null
@@ -1,493 +0,0 @@
-import { describe, expect, it, jest, beforeEach } from '@jest/globals'
-import type { Track } from 'discord-player'
-import type { ChatInputCommandInteraction, EmbedBuilder } from 'discord.js'
-import { handlePlay } from './handlePlay'
-
-const searchContentOnYoutubeMock = jest.fn()
-const interactionReplyMock = jest.fn()
-const createUserFriendlyErrorMock = jest.fn()
-const errorLogMock = jest.fn()
-
-jest.mock('../../../../utils/music/search/searchContentOnYoutube', () => ({
- searchContentOnYoutube: (...args: unknown[]) =>
- searchContentOnYoutubeMock(...args),
-}))
-
-jest.mock('../../../../utils/general/interactionReply', () => ({
- interactionReply: (...args: unknown[]) => interactionReplyMock(...args),
-}))
-
-jest.mock('@lucky/shared/utils/general/errorSanitizer', () => ({
- createUserFriendlyError: (...args: unknown[]) =>
- createUserFriendlyErrorMock(...args),
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-jest.mock('../../../../utils/general/messages', () => ({
- messages: {
- error: {
- noQuery: '❌ You need to provide a search term or URL.',
- noResult: '❌ No results found.',
- },
- },
-}))
-
-function createEmbed(overrides: Partial = {}): EmbedBuilder {
- return {
- setColor: jest.fn(function (this: EmbedBuilder) {
- return this
- }),
- setDescription: jest.fn(function (this: EmbedBuilder) {
- return this
- }),
- setThumbnail: jest.fn(function (this: EmbedBuilder) {
- return this
- }),
- ...overrides,
- } as unknown as EmbedBuilder
-}
-
-function createInteraction(
- query: string | null = null,
-): ChatInputCommandInteraction {
- return {
- options: {
- getString: jest.fn((_option) => query),
- },
- } as unknown as ChatInputCommandInteraction
-}
-
-function createClient() {
- return {
- player: {},
- } as unknown as any
-}
-
-function createQueue() {
- return {
- addTrack: jest.fn(),
- }
-}
-
-describe('handlePlay', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- searchContentOnYoutubeMock.mockClear()
- interactionReplyMock.mockClear()
- createUserFriendlyErrorMock.mockClear()
- errorLogMock.mockClear()
- })
-
- describe('validateQuery', () => {
- it('rejects null query', async () => {
- const embed = createEmbed()
- const interaction = createInteraction(null)
- const client = createClient()
- const queue = createQueue()
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- interaction,
- content: {
- embeds: [embed],
- },
- }),
- )
- expect(embed.setColor).toHaveBeenCalledWith('Red')
- expect(embed.setDescription).toHaveBeenCalledWith(
- '❌ You need to provide a search term or URL.',
- )
- })
-
- it('rejects empty string query', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('')
- const client = createClient()
- const queue = createQueue()
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- interaction,
- content: {
- embeds: [embed],
- },
- }),
- )
- expect(embed.setColor).toHaveBeenCalledWith('Red')
- })
-
- it('rejects undefined query', async () => {
- const embed = createEmbed()
- const interaction = createInteraction(undefined as any)
- const client = createClient()
- const queue = createQueue()
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(interactionReplyMock).toHaveBeenCalled()
- expect(embed.setColor).toHaveBeenCalledWith('Red')
- })
- })
-
- describe('searchAndAddTrack', () => {
- it('successfully searches and adds a track to queue', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('test song')
- const client = createClient()
- const queue = createQueue()
-
- const mockTrack = {
- title: 'Test Song',
- url: 'https://youtube.com/watch?v=test',
- thumbnail: 'https://img.youtube.com/vi/test/maxresdefault.jpg',
- duration: 180000,
- }
-
- searchContentOnYoutubeMock.mockResolvedValue({
- tracks: [mockTrack],
- })
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(searchContentOnYoutubeMock).toHaveBeenCalledWith({
- client,
- searchTerms: 'test song',
- interaction,
- })
-
- expect(queue.addTrack).toHaveBeenCalledWith(
- expect.objectContaining({
- title: mockTrack.title,
- url: mockTrack.url,
- thumbnail: mockTrack.thumbnail,
- duration: mockTrack.duration,
- }),
- )
-
- expect(embed.setColor).toHaveBeenCalledWith('Green')
- expect(embed.setDescription).toHaveBeenCalledWith(
- '✅ Added to queue: **Test Song**',
- )
- expect(embed.setThumbnail).toHaveBeenCalledWith(mockTrack.thumbnail)
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- interaction,
- content: { embeds: [embed] },
- }),
- )
- })
-
- it('logs query to error log', async () => {
- const embed = createEmbed()
- const query = 'test query'
- const interaction = createInteraction(query)
- const client = createClient()
- const queue = createQueue()
-
- const mockTrack = {
- title: 'Test Song',
- url: 'https://youtube.com/watch?v=test',
- thumbnail: 'https://img.youtube.com/vi/test/maxresdefault.jpg',
- duration: 180000,
- }
-
- searchContentOnYoutubeMock.mockResolvedValue({
- tracks: [mockTrack],
- })
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(errorLogMock).toHaveBeenCalledWith({
- message: `Query: ${query}`,
- })
- })
-
- it('handles empty search results', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('nonexistent song')
- const client = createClient()
- const queue = createQueue()
-
- searchContentOnYoutubeMock.mockResolvedValue({
- tracks: [],
- })
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(embed.setColor).toHaveBeenCalledWith('Red')
- expect(embed.setDescription).toHaveBeenCalledWith(
- '❌ No results found.',
- )
- expect(queue.addTrack).not.toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('handles null search result', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('test')
- const client = createClient()
- const queue = createQueue()
-
- searchContentOnYoutubeMock.mockResolvedValue(null)
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(embed.setColor).toHaveBeenCalledWith('Red')
- expect(embed.setDescription).toHaveBeenCalledWith(
- '❌ No results found.',
- )
- expect(queue.addTrack).not.toHaveBeenCalled()
- })
-
- it('uses first track when multiple results are found', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('test')
- const client = createClient()
- const queue = createQueue()
-
- const mockTracks = [
- {
- title: 'First Track',
- url: 'https://youtube.com/watch?v=first',
- thumbnail:
- 'https://img.youtube.com/vi/first/maxresdefault.jpg',
- duration: 180000,
- },
- {
- title: 'Second Track',
- url: 'https://youtube.com/watch?v=second',
- thumbnail:
- 'https://img.youtube.com/vi/second/maxresdefault.jpg',
- duration: 240000,
- },
- ]
-
- searchContentOnYoutubeMock.mockResolvedValue({
- tracks: mockTracks,
- })
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(queue.addTrack).toHaveBeenCalledWith(
- expect.objectContaining({
- title: mockTracks[0].title,
- url: mockTracks[0].url,
- }),
- )
- expect(embed.setDescription).toHaveBeenCalledWith(
- '✅ Added to queue: **First Track**',
- )
- })
-
- it('handles undefined search result tracks', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('test')
- const client = createClient()
- const queue = createQueue()
-
- searchContentOnYoutubeMock.mockResolvedValue({
- tracks: undefined,
- })
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(embed.setColor).toHaveBeenCalledWith('Red')
- expect(embed.setDescription).toHaveBeenCalledWith(
- '❌ No results found.',
- )
- expect(queue.addTrack).not.toHaveBeenCalled()
- })
- })
-
- describe('error handling', () => {
- it('catches search errors and displays user-friendly message', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('test')
- const client = createClient()
- const queue = createQueue()
-
- const searchError = new Error('Network error')
- searchContentOnYoutubeMock.mockRejectedValue(searchError)
- createUserFriendlyErrorMock.mockReturnValue(
- 'Network connection failed. Please try again.',
- )
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(errorLogMock).toHaveBeenCalledWith({
- message: `Error in handlePlay: ${searchError}`,
- })
- expect(createUserFriendlyErrorMock).toHaveBeenCalledWith(
- searchError,
- )
- expect(embed.setColor).toHaveBeenCalledWith('Red')
- expect(embed.setDescription).toHaveBeenCalledWith(
- 'Network connection failed. Please try again.',
- )
- expect(interactionReplyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- interaction,
- content: {
- embeds: [embed],
- },
- }),
- )
- })
-
- it('handles unknown error types', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('test')
- const client = createClient()
- const queue = createQueue()
-
- const unknownError = {
- code: 'UNKNOWN',
- message: 'Something went wrong',
- }
- searchContentOnYoutubeMock.mockRejectedValue(unknownError)
- createUserFriendlyErrorMock.mockReturnValue('An error occurred.')
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(errorLogMock).toHaveBeenCalled()
- expect(createUserFriendlyErrorMock).toHaveBeenCalledWith(
- unknownError,
- )
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('sanitizes errors before displaying', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('test')
- const client = createClient()
- const queue = createQueue()
-
- const technicalError = new Error('ffmpeg failed to initialize')
- searchContentOnYoutubeMock.mockRejectedValue(technicalError)
- createUserFriendlyErrorMock.mockReturnValue(
- 'Audio processing is unavailable. Please try again later.',
- )
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(createUserFriendlyErrorMock).toHaveBeenCalledWith(
- technicalError,
- )
- expect(embed.setDescription).toHaveBeenCalledWith(
- 'Audio processing is unavailable. Please try again later.',
- )
- })
-
- it('does not add track to queue when error occurs', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('test')
- const client = createClient()
- const queue = createQueue()
-
- searchContentOnYoutubeMock.mockRejectedValue(
- new Error('Search failed'),
- )
- createUserFriendlyErrorMock.mockReturnValue('Search failed.')
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(queue.addTrack).not.toHaveBeenCalled()
- })
- })
-
- describe('integration scenarios', () => {
- it('handles full successful flow with query validation', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('taylor swift')
- const client = createClient()
- const queue = createQueue()
-
- const mockTrack = {
- title: 'Blank Space',
- url: 'https://youtube.com/watch?v=e-ORMU3nJoI',
- thumbnail:
- 'https://img.youtube.com/vi/e-ORMU3nJoI/maxresdefault.jpg',
- duration: 231000,
- }
-
- searchContentOnYoutubeMock.mockResolvedValue({
- tracks: [mockTrack],
- })
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(errorLogMock).toHaveBeenCalledWith({
- message: 'Query: taylor swift',
- })
- expect(searchContentOnYoutubeMock).toHaveBeenCalledWith(
- expect.objectContaining({
- searchTerms: 'taylor swift',
- }),
- )
- expect(queue.addTrack).toHaveBeenCalled()
- expect(embed.setColor).toHaveBeenCalledWith('Green')
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('returns early when query validation fails', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('')
- const client = createClient()
- const queue = createQueue()
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(searchContentOnYoutubeMock).not.toHaveBeenCalled()
- expect(queue.addTrack).not.toHaveBeenCalled()
- expect(interactionReplyMock).toHaveBeenCalled()
- })
-
- it('properly chains embed modifications', async () => {
- const embed = createEmbed()
- const interaction = createInteraction('test')
- const client = createClient()
- const queue = createQueue()
-
- const mockTrack = {
- title: 'Test Track',
- url: 'https://youtube.com/watch?v=test',
- thumbnail: 'https://img.youtube.com/vi/test/maxresdefault.jpg',
- duration: 200000,
- }
-
- searchContentOnYoutubeMock.mockResolvedValue({
- tracks: [mockTrack],
- })
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(embed.setColor).toHaveBeenCalledWith('Green')
- expect(embed.setDescription).toHaveBeenCalledWith(
- expect.stringContaining('Added to queue'),
- )
- expect(embed.setThumbnail).toHaveBeenCalledWith(mockTrack.thumbnail)
- })
-
- it('handles whitespace-only query as invalid', async () => {
- const embed = createEmbed()
- const interaction = createInteraction(' ')
- const client = createClient()
- const queue = createQueue()
-
- await handlePlay({ client, interaction, queue, embed })
-
- expect(searchContentOnYoutubeMock).toHaveBeenCalledWith(
- expect.objectContaining({
- searchTerms: ' ',
- }),
- )
- })
- })
-})
diff --git a/packages/bot/src/handlers/auditHandler.spec.ts b/packages/bot/src/handlers/auditHandler.spec.ts
deleted file mode 100644
index eb129b822..000000000
--- a/packages/bot/src/handlers/auditHandler.spec.ts
+++ /dev/null
@@ -1,995 +0,0 @@
-import { jest } from '@jest/globals'
-import { Events, AuditLogEvent, ChannelType } from 'discord.js'
-import type {
- Client,
- Message,
- PartialMessage,
- GuildChannel,
- Role,
- Guild,
- GuildAuditLogsEntry,
-} from 'discord.js'
-import { handleAuditEvents } from './auditHandler'
-
-// Mock dependencies
-jest.mock('@lucky/shared/services', () => ({
- serverLogService: {
- createLog: jest.fn().mockResolvedValue(undefined),
- },
- featureToggleService: {
- isEnabled: jest.fn().mockResolvedValue(true),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- debugLog: jest.fn(),
- errorLog: jest.fn(),
-}))
-
-import { serverLogService } from '@lucky/shared/services'
-import { featureToggleService } from '@lucky/shared/services'
-import { errorLog, debugLog } from '@lucky/shared/utils'
-
-// Create mock client
-function createMockClient(): Client & {
- eventHandlers: Map
-} {
- const eventHandlers = new Map()
-
- const client = {
- on: jest.fn((event: string, handler: Function) => {
- if (!eventHandlers.has(event)) {
- eventHandlers.set(event, [])
- }
- eventHandlers.get(event)?.push(handler)
- return client
- }),
- eventHandlers,
- } as any
-
- return client
-}
-
-// Helper to trigger events
-async function triggerEvent(
- client: ReturnType,
- event: string,
- ...args: any[]
-): Promise {
- const handlers = client.eventHandlers.get(event) || []
- for (const handler of handlers) {
- await handler(...args)
- }
-}
-
-describe('auditHandler', () => {
- let client: ReturnType
-
- beforeEach(() => {
- jest.clearAllMocks()
- client = createMockClient()
- ;(featureToggleService.isEnabled as jest.Mock).mockResolvedValue(true)
- })
-
- describe('handleAuditEvents', () => {
- it('should register all event handlers', () => {
- handleAuditEvents(client as any)
-
- expect(client.on).toHaveBeenCalledWith(
- Events.MessageDelete,
- expect.any(Function),
- )
- expect(client.on).toHaveBeenCalledWith(
- Events.MessageUpdate,
- expect.any(Function),
- )
- expect(client.on).toHaveBeenCalledWith(
- Events.GuildBanAdd,
- expect.any(Function),
- )
- expect(client.on).toHaveBeenCalledWith(
- Events.GuildBanRemove,
- expect.any(Function),
- )
- expect(client.on).toHaveBeenCalledWith(
- Events.ChannelCreate,
- expect.any(Function),
- )
- expect(client.on).toHaveBeenCalledWith(
- Events.ChannelDelete,
- expect.any(Function),
- )
- expect(client.on).toHaveBeenCalledWith(
- Events.GuildRoleCreate,
- expect.any(Function),
- )
- expect(client.on).toHaveBeenCalledWith(
- Events.GuildRoleDelete,
- expect.any(Function),
- )
- })
- })
-
- describe('MessageDelete event', () => {
- it('should log message delete when feature is enabled', async () => {
- handleAuditEvents(client as any)
-
- const message = {
- guild: { id: 'guild-1', name: 'Test Guild' },
- author: { bot: false, id: 'user-1', tag: 'User#0001' },
- content: 'Test message',
- channelId: 'channel-1',
- } as unknown as Message
-
- await triggerEvent(client, Events.MessageDelete, message)
-
- expect(featureToggleService.isEnabled).toHaveBeenCalledWith(
- 'SERVER_LOGS',
- { guildId: 'guild-1' },
- )
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'message_delete',
- 'Message deleted',
- {
- content: 'Test message',
- authorId: 'user-1',
- authorTag: 'User#0001',
- },
- {
- userId: 'user-1',
- channelId: 'channel-1',
- },
- )
- expect(debugLog).toHaveBeenCalledWith({
- message: 'Logged message delete in Test Guild',
- })
- })
-
- it('should not log when guild is missing', async () => {
- handleAuditEvents(client as any)
-
- const message = {
- guild: null,
- author: { bot: false },
- } as unknown as Message
-
- await triggerEvent(client, Events.MessageDelete, message)
-
- expect(serverLogService.createLog).not.toHaveBeenCalled()
- })
-
- it('should not log when author is a bot', async () => {
- handleAuditEvents(client as any)
-
- const message = {
- guild: { id: 'guild-1' },
- author: { bot: true },
- } as unknown as Message
-
- await triggerEvent(client, Events.MessageDelete, message)
-
- expect(serverLogService.createLog).not.toHaveBeenCalled()
- })
-
- it('should not log when feature is disabled', async () => {
- ;(featureToggleService.isEnabled as jest.Mock).mockResolvedValue(
- false,
- )
- handleAuditEvents(client as any)
-
- const message = {
- guild: { id: 'guild-1' },
- author: { bot: false, id: 'user-1', tag: 'User#0001' },
- content: 'Test message',
- channelId: 'channel-1',
- } as unknown as Message
-
- await triggerEvent(client, Events.MessageDelete, message)
-
- expect(serverLogService.createLog).not.toHaveBeenCalled()
- })
-
- it('should handle long message content by truncating', async () => {
- handleAuditEvents(client as any)
-
- const longContent = 'a'.repeat(600)
- const message = {
- guild: { id: 'guild-1', name: 'Test Guild' },
- author: { bot: false, id: 'user-1', tag: 'User#0001' },
- content: longContent,
- channelId: 'channel-1',
- } as unknown as Message
-
- await triggerEvent(client, Events.MessageDelete, message)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'message_delete',
- 'Message deleted',
- expect.objectContaining({
- content: 'a'.repeat(500),
- }),
- expect.any(Object),
- )
- })
-
- it('should handle errors during logging', async () => {
- ;(serverLogService.createLog as jest.Mock).mockRejectedValue(
- new Error('DB error'),
- )
- handleAuditEvents(client as any)
-
- const message = {
- guild: { id: 'guild-1' },
- author: { bot: false, id: 'user-1', tag: 'User#0001' },
- content: 'Test',
- channelId: 'channel-1',
- } as unknown as Message
-
- await triggerEvent(client, Events.MessageDelete, message)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error logging message delete:',
- error: expect.any(Error),
- })
- })
- })
-
- describe('MessageUpdate event', () => {
- it('should log message edit when content changes', async () => {
- handleAuditEvents(client as any)
-
- const oldMessage = {
- content: 'Old content',
- } as PartialMessage
-
- const newMessage = {
- guild: { id: 'guild-1', name: 'Test Guild' },
- author: { bot: false, id: 'user-1', tag: 'User#0001' },
- content: 'New content',
- channelId: 'channel-1',
- } as unknown as Message
-
- await triggerEvent(
- client,
- Events.MessageUpdate,
- oldMessage,
- newMessage,
- )
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'message_edit',
- 'Message edited',
- {
- oldContent: 'Old content',
- newContent: 'New content',
- authorId: 'user-1',
- authorTag: 'User#0001',
- },
- {
- userId: 'user-1',
- channelId: 'channel-1',
- },
- )
- })
-
- it('should not log when content is unchanged', async () => {
- handleAuditEvents(client as any)
-
- const oldMessage = {
- content: 'Same content',
- } as PartialMessage
-
- const newMessage = {
- guild: { id: 'guild-1' },
- author: { bot: false },
- content: 'Same content',
- } as unknown as Message
-
- await triggerEvent(
- client,
- Events.MessageUpdate,
- oldMessage,
- newMessage,
- )
-
- expect(serverLogService.createLog).not.toHaveBeenCalled()
- })
-
- it('should not log when author is a bot', async () => {
- handleAuditEvents(client as any)
-
- const oldMessage = {
- content: 'Old',
- } as PartialMessage
-
- const newMessage = {
- guild: { id: 'guild-1' },
- author: { bot: true },
- content: 'New',
- } as unknown as Message
-
- await triggerEvent(
- client,
- Events.MessageUpdate,
- oldMessage,
- newMessage,
- )
-
- expect(serverLogService.createLog).not.toHaveBeenCalled()
- })
- })
-
- describe('GuildBanAdd event', () => {
- it('should log member ban with audit log data', async () => {
- const firstEntry = {
- reason: 'Spam',
- executor: { id: 'mod-1' },
- } as unknown as GuildAuditLogsEntry
-
- const mockAuditLog = {
- entries: {
- first: jest.fn().mockReturnValue(firstEntry),
- },
- }
-
- const ban = {
- user: {
- id: 'user-1',
- username: 'TestUser',
- tag: 'TestUser#0001',
- },
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockResolvedValue(mockAuditLog),
- },
- } as any
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildBanAdd, ban)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'mod_action',
- 'Member banned',
- {
- userId: 'user-1',
- username: 'TestUser',
- tag: 'TestUser#0001',
- reason: 'Spam',
- },
- {
- userId: 'user-1',
- moderatorId: 'mod-1',
- },
- )
- })
-
- it('should handle missing audit log gracefully', async () => {
- const ban = {
- user: {
- id: 'user-1',
- username: 'TestUser',
- tag: 'TestUser#0001',
- },
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockRejectedValue(new Error()),
- },
- } as any
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildBanAdd, ban)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'mod_action',
- 'Member banned',
- expect.objectContaining({
- reason: 'No reason provided',
- }),
- expect.objectContaining({
- moderatorId: undefined,
- }),
- )
- })
- })
-
- describe('GuildBanRemove event', () => {
- it('should log member unban with audit log data', async () => {
- const firstEntry = {
- reason: 'Appeal accepted',
- executor: { id: 'mod-1' },
- } as unknown as GuildAuditLogsEntry
-
- const mockAuditLog = {
- entries: {
- first: jest.fn().mockReturnValue(firstEntry),
- },
- }
-
- const ban = {
- user: {
- id: 'user-1',
- username: 'TestUser',
- tag: 'TestUser#0001',
- },
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockResolvedValue(mockAuditLog),
- },
- } as any
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildBanRemove, ban)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'mod_action',
- 'Member unbanned',
- {
- userId: 'user-1',
- username: 'TestUser',
- tag: 'TestUser#0001',
- reason: 'Appeal accepted',
- },
- {
- userId: 'user-1',
- moderatorId: 'mod-1',
- },
- )
- })
- })
-
- describe('ChannelCreate event', () => {
- it('should log channel creation', async () => {
- const firstEntry = {
- executor: { id: 'mod-1' },
- } as unknown as GuildAuditLogsEntry
-
- const mockAuditLog = {
- entries: {
- first: jest.fn().mockReturnValue(firstEntry),
- },
- }
-
- const channel = {
- id: 'channel-1',
- name: 'new-channel',
- type: ChannelType.GuildText,
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockResolvedValue(mockAuditLog),
- },
- } as unknown as GuildChannel
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.ChannelCreate, channel)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'channel_update',
- 'Channel created',
- {
- channelId: 'channel-1',
- channelName: 'new-channel',
- channelType: ChannelType.GuildText,
- },
- {
- channelId: 'channel-1',
- moderatorId: 'mod-1',
- },
- )
- })
-
- it('should skip non-guild channels', async () => {
- const channel = {
- id: 'dm-1',
- type: ChannelType.DM,
- } as any
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.ChannelCreate, channel)
-
- expect(serverLogService.createLog).not.toHaveBeenCalled()
- })
-
- it('should handle audit log errors', async () => {
- const channel = {
- id: 'channel-1',
- name: 'new-channel',
- type: ChannelType.GuildText,
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockRejectedValue(new Error()),
- },
- } as unknown as GuildChannel
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.ChannelCreate, channel)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'channel_update',
- 'Channel created',
- expect.any(Object),
- expect.objectContaining({
- moderatorId: undefined,
- }),
- )
- })
- })
-
- describe('ChannelDelete event', () => {
- it('should log channel deletion', async () => {
- const firstEntry = {
- executor: { id: 'mod-1' },
- } as unknown as GuildAuditLogsEntry
-
- const mockAuditLog = {
- entries: {
- first: jest.fn().mockReturnValue(firstEntry),
- },
- }
-
- const channel = {
- id: 'channel-1',
- name: 'deleted-channel',
- type: ChannelType.GuildVoice,
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockResolvedValue(mockAuditLog),
- },
- } as unknown as GuildChannel
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.ChannelDelete, channel)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'channel_update',
- 'Channel deleted',
- {
- channelId: 'channel-1',
- channelName: 'deleted-channel',
- channelType: ChannelType.GuildVoice,
- },
- {
- channelId: 'channel-1',
- moderatorId: 'mod-1',
- },
- )
- })
-
- it('should skip non-guild channels', async () => {
- const channel = {
- id: 'dm-1',
- } as any
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.ChannelDelete, channel)
-
- expect(serverLogService.createLog).not.toHaveBeenCalled()
- })
- })
-
- describe('Error handling in event wrapper', () => {
- it('should catch and log errors from MessageDelete handler', async () => {
- ;(featureToggleService.isEnabled as jest.Mock).mockRejectedValue(
- new Error('Service error'),
- )
-
- const message = {
- guild: { id: 'guild-1' },
- author: { bot: false },
- } as unknown as Message
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.MessageDelete, message)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error in message delete handler:',
- error: expect.any(Error),
- })
- })
-
- it('should catch and log errors from MessageUpdate handler', async () => {
- ;(featureToggleService.isEnabled as jest.Mock).mockRejectedValue(
- new Error('Service error'),
- )
-
- const oldMessage = {
- content: 'Old',
- } as PartialMessage
-
- const newMessage = {
- guild: { id: 'guild-1' },
- author: { bot: false },
- content: 'New',
- } as unknown as Message
-
- handleAuditEvents(client as any)
- await triggerEvent(
- client,
- Events.MessageUpdate,
- oldMessage,
- newMessage,
- )
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error in message update handler:',
- error: expect.any(Error),
- })
- })
-
- it('should catch and log errors from GuildBanAdd handler', async () => {
- ;(featureToggleService.isEnabled as jest.Mock).mockRejectedValue(
- new Error('Service error'),
- )
-
- const ban = {
- user: { id: 'user-1' },
- guild: { id: 'guild-1' },
- } as any
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildBanAdd, ban)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error in ban add handler:',
- error: expect.any(Error),
- })
- })
-
- it('should catch and log errors from GuildBanRemove handler', async () => {
- ;(featureToggleService.isEnabled as jest.Mock).mockRejectedValue(
- new Error('Service error'),
- )
-
- const ban = {
- user: { id: 'user-1' },
- guild: { id: 'guild-1' },
- } as any
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildBanRemove, ban)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error in ban remove handler:',
- error: expect.any(Error),
- })
- })
-
- it('should catch and log errors from ChannelCreate handler', async () => {
- const channel = {
- id: 'channel-1',
- guild: { id: 'guild-1' },
- } as any
-
- ;(featureToggleService.isEnabled as jest.Mock).mockRejectedValue(
- new Error('Service error'),
- )
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.ChannelCreate, channel)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error in channel create handler:',
- error: expect.any(Error),
- })
- })
-
- it('should catch and log errors from ChannelDelete handler', async () => {
- const channel = {
- id: 'channel-1',
- guild: { id: 'guild-1' },
- } as any
-
- ;(featureToggleService.isEnabled as jest.Mock).mockRejectedValue(
- new Error('Service error'),
- )
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.ChannelDelete, channel)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error in channel delete handler:',
- error: expect.any(Error),
- })
- })
-
- it('should catch and log errors from GuildRoleCreate handler', async () => {
- const role = {
- id: 'role-1',
- guild: { id: 'guild-1' },
- } as any
-
- ;(featureToggleService.isEnabled as jest.Mock).mockRejectedValue(
- new Error('Service error'),
- )
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleCreate, role)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error in role create handler:',
- error: expect.any(Error),
- })
- })
-
- it('should catch and log errors from GuildRoleDelete handler', async () => {
- const role = {
- id: 'role-1',
- guild: { id: 'guild-1' },
- } as any
-
- ;(featureToggleService.isEnabled as jest.Mock).mockRejectedValue(
- new Error('Service error'),
- )
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleDelete, role)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error in role delete handler:',
- error: expect.any(Error),
- })
- })
- })
-
- describe('GuildRoleCreate event', () => {
- it('should log role creation with audit log data', async () => {
- const firstEntry = {
- executor: { id: 'mod-1' },
- } as unknown as GuildAuditLogsEntry
-
- const mockAuditLog = {
- entries: {
- first: jest.fn().mockReturnValue(firstEntry),
- },
- }
-
- const role = {
- id: 'role-1',
- name: 'Moderator',
- color: 0x00ff00,
- permissions: { bitfield: BigInt(8) },
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockResolvedValue(mockAuditLog),
- },
- } as unknown as Role
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleCreate, role)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'role_update',
- 'Role created',
- {
- roleId: 'role-1',
- roleName: 'Moderator',
- color: 0x00ff00,
- permissions: '8',
- },
- {
- moderatorId: 'mod-1',
- },
- )
- expect(debugLog).toHaveBeenCalledWith({
- message: 'Logged role created in Test Guild',
- })
- })
-
- it('should not log when feature is disabled', async () => {
- ;(featureToggleService.isEnabled as jest.Mock).mockResolvedValue(
- false,
- )
-
- const role = {
- id: 'role-1',
- name: 'Test Role',
- guild: {
- id: 'guild-1',
- },
- } as unknown as Role
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleCreate, role)
-
- expect(serverLogService.createLog).not.toHaveBeenCalled()
- })
-
- it('should handle missing audit log gracefully', async () => {
- const role = {
- id: 'role-1',
- name: 'Test Role',
- color: 0xff0000,
- permissions: { bitfield: BigInt(0) },
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockRejectedValue(new Error()),
- },
- } as unknown as Role
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleCreate, role)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'role_update',
- 'Role created',
- expect.objectContaining({
- roleId: 'role-1',
- roleName: 'Test Role',
- }),
- expect.objectContaining({
- moderatorId: undefined,
- }),
- )
- })
-
- it('should handle errors during logging', async () => {
- ;(serverLogService.createLog as jest.Mock).mockRejectedValue(
- new Error('DB error'),
- )
-
- const role = {
- id: 'role-1',
- name: 'Test Role',
- color: 0,
- permissions: { bitfield: BigInt(0) },
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockResolvedValue({
- entries: { first: jest.fn().mockReturnValue(null) },
- }),
- },
- } as unknown as Role
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleCreate, role)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error logging role created:',
- error: expect.any(Error),
- })
- })
- })
-
- describe('GuildRoleDelete event', () => {
- it('should log role deletion with audit log data', async () => {
- const firstEntry = {
- executor: { id: 'mod-1' },
- } as unknown as GuildAuditLogsEntry
-
- const mockAuditLog = {
- entries: {
- first: jest.fn().mockReturnValue(firstEntry),
- },
- }
-
- const role = {
- id: 'role-1',
- name: 'Spammer',
- color: 0xff0000,
- permissions: { bitfield: BigInt(0) },
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockResolvedValue(mockAuditLog),
- },
- } as unknown as Role
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleDelete, role)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'role_update',
- 'Role deleted',
- {
- roleId: 'role-1',
- roleName: 'Spammer',
- color: 0xff0000,
- permissions: '0',
- },
- {
- moderatorId: 'mod-1',
- },
- )
- expect(debugLog).toHaveBeenCalledWith({
- message: 'Logged role deleted in Test Guild',
- })
- })
-
- it('should not log when feature is disabled', async () => {
- ;(featureToggleService.isEnabled as jest.Mock).mockResolvedValue(
- false,
- )
-
- const role = {
- id: 'role-1',
- name: 'Test Role',
- guild: {
- id: 'guild-1',
- },
- } as unknown as Role
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleDelete, role)
-
- expect(serverLogService.createLog).not.toHaveBeenCalled()
- })
-
- it('should handle missing audit log gracefully', async () => {
- const role = {
- id: 'role-1',
- name: 'Old Role',
- color: 0x0000ff,
- permissions: { bitfield: BigInt(16) },
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockRejectedValue(new Error()),
- },
- } as unknown as Role
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleDelete, role)
-
- expect(serverLogService.createLog).toHaveBeenCalledWith(
- 'guild-1',
- 'role_update',
- 'Role deleted',
- expect.objectContaining({
- roleId: 'role-1',
- roleName: 'Old Role',
- }),
- expect.objectContaining({
- moderatorId: undefined,
- }),
- )
- })
-
- it('should handle errors during logging', async () => {
- ;(serverLogService.createLog as jest.Mock).mockRejectedValue(
- new Error('DB error'),
- )
-
- const role = {
- id: 'role-1',
- name: 'Test Role',
- color: 0,
- permissions: { bitfield: BigInt(0) },
- guild: {
- id: 'guild-1',
- name: 'Test Guild',
- fetchAuditLogs: jest.fn().mockResolvedValue({
- entries: { first: jest.fn().mockReturnValue(null) },
- }),
- },
- } as unknown as Role
-
- handleAuditEvents(client as any)
- await triggerEvent(client, Events.GuildRoleDelete, role)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error logging role deleted:',
- error: expect.any(Error),
- })
- })
- })
-})
diff --git a/packages/bot/src/handlers/commandsHandler.spec.ts b/packages/bot/src/handlers/commandsHandler.spec.ts
index e80ebf41e..e3796916d 100644
--- a/packages/bot/src/handlers/commandsHandler.spec.ts
+++ b/packages/bot/src/handlers/commandsHandler.spec.ts
@@ -79,82 +79,15 @@ describe('commandsHandler', () => {
await executeCommand({ interaction, client })
- expect(monitorCommandExecution).toHaveBeenCalledWith(
- 'test',
- 'user-1',
- 'guild-1',
- )
expect(command.execute).toHaveBeenCalledWith({
interaction,
client,
})
- expect(debugLog).toHaveBeenCalledWith({
- message: 'Executing command: test',
- })
})
- it('should handle missing command gracefully', async () => {
- const interaction = createMockInteraction()
- const client = createMockClient()
- await executeCommand({ interaction, client })
-
- expect(debugLog).toHaveBeenCalledWith({
- message: 'Command not found: test',
- })
- })
- it('should check feature toggle for moderation commands', async () => {
- const command = createMockCommand({ category: 'moderation' })
- const interaction = createMockInteraction()
- const client = createMockClient()
- client.commands.set('test', command)
- await executeCommand({ interaction, client })
-
- expect(featureToggleService.isEnabled).toHaveBeenCalledWith(
- 'MODERATION',
- {
- guildId: 'guild-1',
- userId: 'user-1',
- },
- )
- expect(command.execute).toHaveBeenCalled()
- })
-
- it('should check feature toggle for automod commands', async () => {
- const command = createMockCommand({ category: 'automod' })
- const interaction = createMockInteraction()
- const client = createMockClient()
- client.commands.set('test', command)
-
- await executeCommand({ interaction, client })
-
- expect(featureToggleService.isEnabled).toHaveBeenCalledWith(
- 'AUTOMOD',
- {
- guildId: 'guild-1',
- userId: 'user-1',
- },
- )
- })
-
- it('should check feature toggle for management commands', async () => {
- const command = createMockCommand({ category: 'management' })
- const interaction = createMockInteraction()
- const client = createMockClient()
- client.commands.set('test', command)
-
- await executeCommand({ interaction, client })
-
- expect(featureToggleService.isEnabled).toHaveBeenCalledWith(
- 'AUTO_MESSAGES',
- {
- guildId: 'guild-1',
- userId: 'user-1',
- },
- )
- })
it('should block command when feature toggle is disabled', async () => {
;(featureToggleService.isEnabled as jest.Mock).mockResolvedValue(
@@ -177,22 +110,6 @@ describe('commandsHandler', () => {
expect(command.execute).not.toHaveBeenCalled()
})
- it('should handle interaction without guild', async () => {
- const command = createMockCommand({ category: 'moderation' })
- const interaction = createMockInteraction({ guild: null })
- const client = createMockClient()
- client.commands.set('test', command)
-
- await executeCommand({ interaction, client })
-
- expect(featureToggleService.isEnabled).toHaveBeenCalledWith(
- 'MODERATION',
- {
- guildId: undefined,
- userId: 'user-1',
- },
- )
- })
it('should handle command execution errors', async () => {
const command = createMockCommand()
@@ -264,12 +181,6 @@ describe('commandsHandler', () => {
expect(client.commands.has('ping')).toBe(true)
expect(client.commands.has('help')).toBe(true)
expect(client.commands.has('stats')).toBe(true)
- expect(debugLog).toHaveBeenCalledWith({
- message: 'Setting commands in client collection...',
- })
- expect(debugLog).toHaveBeenCalledWith({
- message: 'Loaded 3 commands',
- })
})
it('should skip commands without name', async () => {
@@ -327,9 +238,6 @@ describe('commandsHandler', () => {
await setCommands({ client, commands: [] })
expect(client.commands.size).toBe(0)
- expect(debugLog).toHaveBeenCalledWith({
- message: 'Loaded 0 commands',
- })
})
})
diff --git a/packages/bot/src/handlers/eventHandler.spec.ts b/packages/bot/src/handlers/eventHandler.spec.ts
index 48f9520fb..69ef598b8 100644
--- a/packages/bot/src/handlers/eventHandler.spec.ts
+++ b/packages/bot/src/handlers/eventHandler.spec.ts
@@ -402,18 +402,6 @@ describe('eventHandler', () => {
expect(cleanupGuildStateMock).toHaveBeenCalledWith('guild-delete-123')
})
- it('logs error when guild delete cleanup fails', async () => {
- const { client, onMock } = createMockClient()
- handleEvents(client as unknown as never)
-
- const handler = getGuildDeleteHandler(onMock)
- const mockGuild = { id: 'guild-error-456' }
-
- await handler?.(mockGuild)
-
- expect(cleanupGuildStateMock).toHaveBeenCalled()
- })
-
it('handles errors during guild cleanup gracefully', async () => {
const { client, onMock } = createMockClient()
cleanupGuildStateMock.mockImplementation(() => {
@@ -469,31 +457,6 @@ describe('eventHandler', () => {
expect(cleanupGuildStateMock).not.toHaveBeenCalled()
})
-
- it('logs error when channel cleanup fails', () => {
- const { client, onMock } = createMockClient()
- cleanupGuildStateMock.mockImplementation(() => {
- throw new Error('Channel cleanup failed')
- })
-
- handleEvents(client as unknown as never)
-
- const handler = getChannelDeleteHandler(onMock)
- const mockChannel = {
- guildId: 'guild-error-ch',
- isDMBased: () => false,
- }
-
- handler?.(mockChannel)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining(
- 'Error clearing state on channel delete',
- ),
- }),
- )
- })
})
})
@@ -505,48 +468,6 @@ describe('eventHandler', () => {
return call?.[1] as ((client: unknown) => void) | undefined
}
- it('logs when client is ready', () => {
- const { client, onceMock } = createMockClient()
- const mockClient = {
- ...client,
- user: { tag: 'TestBot#0001' },
- }
-
- handleEvents(mockClient as unknown as never)
-
- const handler = getClientReadyHandler(onceMock)
- expect(handler).toBeDefined()
-
- handler?.({})
-
- expect(infoLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Logged in as'),
- }),
- )
- })
-
- it('logs command count when ready', () => {
- const { client, onceMock } = createMockClient()
- client.commands.set('cmd1', { execute: jest.fn() })
- client.commands.set('cmd2', { execute: jest.fn() })
- const mockClient = {
- ...client,
- user: { tag: 'TestBot#0001' },
- }
-
- handleEvents(mockClient as unknown as never)
-
- const handler = getClientReadyHandler(onceMock)
- handler?.({})
-
- expect(debugLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Bot is ready'),
- }),
- )
- })
-
it('starts ai dev toolkit service when enabled', async () => {
process.env.AI_DEV_TOOLKIT_BOARD_ENABLED = 'true'
aiDevToolkitStartMock.mockResolvedValue(undefined)
@@ -568,32 +489,6 @@ describe('eventHandler', () => {
delete process.env.AI_DEV_TOOLKIT_BOARD_ENABLED
})
-
- it('logs error if ai dev toolkit fails to start', async () => {
- process.env.AI_DEV_TOOLKIT_BOARD_ENABLED = 'true'
- aiDevToolkitStartMock.mockRejectedValue(new Error('Toolkit start failed'))
-
- const { client, onceMock } = createMockClient()
- const mockClient = {
- ...client,
- user: { tag: 'TestBot#0001' },
- }
-
- handleEvents(mockClient as unknown as never)
-
- const handler = getClientReadyHandler(onceMock)
- handler?.(mockClient)
-
- await new Promise((resolve) => setImmediate(resolve))
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('AiDevToolkitService'),
- }),
- )
-
- delete process.env.AI_DEV_TOOLKIT_BOARD_ENABLED
- })
})
})
diff --git a/packages/bot/src/handlers/externalScrobbler.spec.ts b/packages/bot/src/handlers/externalScrobbler.spec.ts
index 104fa77ce..d26d172c0 100644
--- a/packages/bot/src/handlers/externalScrobbler.spec.ts
+++ b/packages/bot/src/handlers/externalScrobbler.spec.ts
@@ -13,25 +13,9 @@ const isLastFmConfiguredMock = jest.fn<() => boolean>()
const getSessionKeyForUserMock =
jest.fn<(discordId: string) => Promise>()
const isLastFmInvalidSessionErrorMock = jest.fn<(error: unknown) => boolean>()
-const updateNowPlayingMock =
- jest.fn<
- (
- artist: string,
- track: string,
- durationSec?: number,
- sessionKey?: string | null,
- ) => Promise
- >()
-const scrobbleMock =
- jest.fn<
- (
- artist: string,
- track: string,
- timestamp: number,
- durationSec?: number,
- sessionKey?: string | null,
- ) => Promise
- >()
+const getTrackMetadataMock = jest.fn()
+const updateNowPlayingMock = jest.fn()
+const scrobbleMock = jest.fn()
const lastFmUnlinkMock = jest.fn<(discordId: string) => Promise>()
const infoLogMock = jest.fn<(payload: unknown) => void>()
const errorLogMock = jest.fn<(payload: unknown) => void>()
@@ -43,19 +27,9 @@ jest.mock('../lastfm', () => ({
getSessionKeyForUserMock(discordId),
isLastFmInvalidSessionError: (error: unknown) =>
isLastFmInvalidSessionErrorMock(error),
- updateNowPlaying: (
- artist: string,
- track: string,
- durationSec?: number,
- sessionKey?: string | null,
- ) => updateNowPlayingMock(artist, track, durationSec, sessionKey),
- scrobble: (
- artist: string,
- track: string,
- timestamp: number,
- durationSec?: number,
- sessionKey?: string | null,
- ) => scrobbleMock(artist, track, timestamp, durationSec, sessionKey),
+ getTrackMetadata: (...args: unknown[]) => getTrackMetadataMock(...args),
+ updateNowPlaying: (...args: unknown[]) => updateNowPlayingMock(...args),
+ scrobble: (...args: unknown[]) => scrobbleMock(...args),
}))
jest.mock('@lucky/shared/services', () => ({
@@ -148,6 +122,9 @@ describe('externalScrobbler', () => {
getSessionKeyForUserMock.mockResolvedValue('session-1')
isLastFmInvalidSessionErrorMock.mockReturnValue(false)
lastFmUnlinkMock.mockResolvedValue(true)
+ getTrackMetadataMock.mockResolvedValue(null)
+ updateNowPlayingMock.mockResolvedValue(undefined)
+ scrobbleMock.mockResolvedValue(undefined)
})
afterEach(() => {
@@ -167,10 +144,29 @@ describe('externalScrobbler', () => {
'My Song',
undefined,
'session-1',
+ undefined,
)
expect(getSessionKeyForUserMock).toHaveBeenCalledWith('user-1')
})
+ it('forwards metadata to updateNowPlaying when available', async () => {
+ const testMeta = { mbid: 'test-mbid', album: 'Test Album' }
+ getTrackMetadataMock.mockResolvedValue(testMeta)
+ const { guild, handler } = createHarness('guild-meta-1')
+
+ await handler(
+ createMessage('**Now playing: My Artist – My Song**', guild),
+ )
+
+ expect(updateNowPlayingMock).toHaveBeenCalledWith(
+ 'My Artist',
+ 'My Song',
+ undefined,
+ 'session-1',
+ testMeta,
+ )
+ })
+
it('scrobbles previous track on next now-playing event after 30 seconds', async () => {
dateNowSpy = jest.spyOn(Date, 'now')
dateNowSpy
@@ -193,12 +189,38 @@ describe('externalScrobbler', () => {
100,
40,
'session-1',
+ undefined,
)
expect(updateNowPlayingMock).toHaveBeenCalledWith(
'Second Artist',
'Second Song',
undefined,
'session-1',
+ undefined,
+ )
+ })
+
+ it('forwards metadata to scrobble when available', async () => {
+ const testMeta = { mbid: 'scrobble-mbid' }
+ getTrackMetadataMock.mockResolvedValue(testMeta)
+ dateNowSpy = jest.spyOn(Date, 'now')
+ dateNowSpy
+ .mockReturnValueOnce(100000)
+ .mockReturnValueOnce(140000)
+ .mockReturnValueOnce(140000)
+
+ const { guild, handler } = createHarness('guild-meta-2')
+
+ await handler(createMessage('Now playing: First Artist — First Song', guild))
+ await handler(createMessage('Now playing: Second Artist - Second Song', guild))
+
+ expect(scrobbleMock).toHaveBeenCalledWith(
+ 'First Artist',
+ 'First Song',
+ 100,
+ 40,
+ 'session-1',
+ testMeta,
)
})
diff --git a/packages/bot/src/handlers/externalScrobbler.ts b/packages/bot/src/handlers/externalScrobbler.ts
index e147f6c09..73cd6dbd4 100644
--- a/packages/bot/src/handlers/externalScrobbler.ts
+++ b/packages/bot/src/handlers/externalScrobbler.ts
@@ -9,6 +9,7 @@ import { lastFmLinkService } from '@lucky/shared/services'
import {
isLastFmConfigured,
getSessionKeyForUser,
+ getTrackMetadata,
isLastFmInvalidSessionError,
updateNowPlaying,
scrobble,
@@ -105,6 +106,7 @@ async function scrobblePreviousTrack(guildId: string): Promise {
const guild = globalClient?.guilds.cache.get(guildId)
if (!guild) return
+ const meta = await getTrackMetadata(prev.artist, prev.title)
const voiceChannels = guild.channels.cache.filter(
(ch) => ch.isVoiceBased() && ch.members.size > 0,
)
@@ -124,6 +126,7 @@ async function scrobblePreviousTrack(guildId: string): Promise {
prev.timestamp,
elapsed,
sessionKey,
+ meta ?? undefined,
)
debugLog({
message: `Scrobbled (external): ${prev.artist} – ${prev.title} for ${member.user.username}`,
@@ -167,6 +170,7 @@ async function handleExternalNowPlaying(message: Message): Promise {
timestamp: Math.floor(Date.now() / 1000),
})
+ const meta = await getTrackMetadata(parsed.artist, parsed.title)
const voiceChannel = getMusicBotVoiceChannel(message)
if (!voiceChannel) {
debugLog({ message: 'Music bot not in a voice channel' })
@@ -185,6 +189,7 @@ async function handleExternalNowPlaying(message: Message): Promise {
parsed.title,
undefined,
sessionKey,
+ meta ?? undefined,
)
infoLog({
message: `Last.fm now playing: ${parsed.artist} – ${parsed.title} for ${member.user.username}`,
diff --git a/packages/bot/src/handlers/interactionHandler.spec.ts b/packages/bot/src/handlers/interactionHandler.spec.ts
index ba17920f7..4b3bd8e1f 100644
--- a/packages/bot/src/handlers/interactionHandler.spec.ts
+++ b/packages/bot/src/handlers/interactionHandler.spec.ts
@@ -131,52 +131,6 @@ describe('interactionHandler', () => {
})
describe('handleInteractions', () => {
- it('should register interaction event listener', async () => {
- const client = createMockClient()
-
- await handleInteractions({ client })
-
- expect(client.on).toHaveBeenCalledWith(
- Events.InteractionCreate,
- expect.any(Function),
- )
- expect(debugLog).toHaveBeenCalledWith({
- message: 'Interaction handler set up successfully',
- })
- })
-
- it('should handle setup errors', async () => {
- const client = {
- on: jest.fn().mockImplementation(() => {
- throw new Error('Setup error')
- }),
- } as any
-
- await handleInteractions({ client })
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error setting up interaction handler:',
- error: expect.any(Error),
- })
- })
-
- it('should catch async errors in interaction handler', async () => {
- const client = createMockClient()
- ;(executeCommand as jest.Mock).mockRejectedValue(
- new Error('Handler error'),
- )
-
- await handleInteractions({ client })
-
- const interaction = createMockChatInteraction()
- await triggerEvent(client, Events.InteractionCreate, interaction)
-
- expect(errorLog).toHaveBeenCalledWith({
- message: 'Error handling interaction:',
- error: expect.any(Error),
- data: expect.any(Object),
- })
- })
})
describe('handleInteraction', () => {
@@ -194,42 +148,6 @@ describe('interactionHandler', () => {
expect(executeCommand).toHaveBeenCalledWith({ interaction, client })
})
- it('should route music button to handleMusicButtonInteraction', async () => {
- const interaction = createMockButtonInteraction('music_play')
- const client = createMockClient()
-
- await handleInteraction(interaction, client)
-
- expect(handleMusicButtonInteraction).toHaveBeenCalledWith(
- interaction,
- )
- expect(
- reactionRolesService.handleButtonInteraction,
- ).not.toHaveBeenCalled()
- })
-
- it('should route queue page button to handleMusicButtonInteraction', async () => {
- const interaction = createMockButtonInteraction('queue_page_2')
- const client = createMockClient()
-
- await handleInteraction(interaction, client)
-
- expect(handleMusicButtonInteraction).toHaveBeenCalledWith(
- interaction,
- )
- })
-
- it('should route non-music buttons to reactionRolesService', async () => {
- const interaction = createMockButtonInteraction('role_selector')
- const client = createMockClient()
-
- await handleInteraction(interaction, client)
-
- expect(
- reactionRolesService.handleButtonInteraction,
- ).toHaveBeenCalledWith(interaction)
- expect(handleMusicButtonInteraction).not.toHaveBeenCalled()
- })
it('should handle interaction without guild', async () => {
const interaction = createMockChatInteraction({ guild: null })
diff --git a/packages/bot/src/handlers/memberHandler.spec.ts b/packages/bot/src/handlers/memberHandler.spec.ts
index 3c00d6b50..471e52d55 100644
--- a/packages/bot/src/handlers/memberHandler.spec.ts
+++ b/packages/bot/src/handlers/memberHandler.spec.ts
@@ -63,21 +63,6 @@ describe('memberHandler', () => {
;(featureToggleService.isEnabled as jest.Mock).mockResolvedValue(true)
})
- describe('handleMemberEvents', () => {
- it('should register event handlers', () => {
- client = createMockClient()
- handleMemberEvents(client as any)
-
- expect(client.on).toHaveBeenCalledWith(
- Events.GuildMemberAdd,
- expect.any(Function),
- )
- expect(client.on).toHaveBeenCalledWith(
- Events.GuildMemberRemove,
- expect.any(Function),
- )
- })
- })
describe('GuildMemberAdd event', () => {
beforeEach(() => {
@@ -303,67 +288,6 @@ describe('memberHandler', () => {
expect(mockSend).toHaveBeenCalledWith('Hello <@user>')
})
- it('should not send when guild is missing', async () => {
- const member = {
- guild: null,
- } as unknown as GuildMember
-
- handleMemberEvents(client as any)
- await triggerEvent(client, Events.GuildMemberAdd, member)
-
- expect(autoMessageService.getWelcomeMessage).not.toHaveBeenCalled()
- })
-
- it('should not send when feature is disabled', async () => {
- ;(featureToggleService.isEnabled as jest.Mock).mockResolvedValue(
- false,
- )
-
- const member = {
- guild: { id: 'guild-1' },
- } as unknown as GuildMember
-
- handleMemberEvents(client as any)
- await triggerEvent(client, Events.GuildMemberAdd, member)
-
- expect(autoMessageService.getWelcomeMessage).not.toHaveBeenCalled()
- })
-
- it('should not send when welcome message is not enabled', async () => {
- ;(
- autoMessageService.getWelcomeMessage as jest.Mock
- ).mockResolvedValue({
- enabled: false,
- message: 'Welcome!',
- })
-
- const member = {
- guild: {
- id: 'guild-1',
- systemChannel: { send: jest.fn() },
- },
- } as unknown as GuildMember
-
- handleMemberEvents(client as any)
- await triggerEvent(client, Events.GuildMemberAdd, member)
-
- expect(debugLog).not.toHaveBeenCalled()
- })
-
- it('should not send when welcome message is null', async () => {
- ;(
- autoMessageService.getWelcomeMessage as jest.Mock
- ).mockResolvedValue(null)
-
- const member = {
- guild: { id: 'guild-1' },
- } as unknown as GuildMember
-
- handleMemberEvents(client as any)
- await triggerEvent(client, Events.GuildMemberAdd, member)
-
- expect(debugLog).not.toHaveBeenCalled()
- })
it('should not send when no suitable channel found', async () => {
;(
@@ -576,49 +500,6 @@ describe('memberHandler', () => {
expect(mockSend).toHaveBeenCalledWith('Bye <@user>')
})
- it('should not send when guild is missing', async () => {
- const member = {
- guild: null,
- } as unknown as PartialGuildMember
-
- handleMemberEvents(client as any)
- await triggerEvent(client, Events.GuildMemberRemove, member)
-
- expect(autoMessageService.getLeaveMessage).not.toHaveBeenCalled()
- })
-
- it('should not send when feature is disabled', async () => {
- ;(featureToggleService.isEnabled as jest.Mock).mockResolvedValue(
- false,
- )
-
- const member = {
- guild: { id: 'guild-1' },
- } as unknown as PartialGuildMember
-
- handleMemberEvents(client as any)
- await triggerEvent(client, Events.GuildMemberRemove, member)
-
- expect(autoMessageService.getLeaveMessage).not.toHaveBeenCalled()
- })
-
- it('should not send when leave message is disabled', async () => {
- ;(
- autoMessageService.getLeaveMessage as jest.Mock
- ).mockResolvedValue({
- enabled: false,
- message: 'Bye',
- })
-
- const member = {
- guild: { id: 'guild-1' },
- } as unknown as PartialGuildMember
-
- handleMemberEvents(client as any)
- await triggerEvent(client, Events.GuildMemberRemove, member)
-
- expect(debugLog).not.toHaveBeenCalled()
- })
it('should not send when no suitable channel found', async () => {
;(
diff --git a/packages/bot/src/handlers/messageHandler.spec.ts b/packages/bot/src/handlers/messageHandler.spec.ts
deleted file mode 100644
index 53533c5c1..000000000
--- a/packages/bot/src/handlers/messageHandler.spec.ts
+++ /dev/null
@@ -1,619 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-
-const getConfigMock = jest.fn()
-const getMemberXPMock = jest.fn()
-const addXPMock = jest.fn()
-const getRewardsMock = jest.fn()
-const errorLogMock = jest.fn()
-const debugLogMock = jest.fn()
-const getSettingsMock = jest.fn()
-const trackMessageAndCheckSpamMock = jest.fn()
-const checkCapsMock = jest.fn()
-const checkLinksMock = jest.fn()
-const checkInvitesMock = jest.fn()
-const checkWordsMock = jest.fn()
-const listCommandsMock = jest.fn()
-const incrementUsageMock = jest.fn()
-const createCaseMock = jest.fn()
-const isEnabledMock = jest.fn()
-
-jest.mock('@lucky/shared/services', () => ({
- levelService: {
- getConfig: (...args: unknown[]) => getConfigMock(...args),
- getMemberXP: (...args: unknown[]) => getMemberXPMock(...args),
- addXP: (...args: unknown[]) => addXPMock(...args),
- getRewards: (...args: unknown[]) => getRewardsMock(...args),
- },
- autoModService: {
- getSettings: (...args: unknown[]) => getSettingsMock(...args),
- trackMessageAndCheckSpam: (...args: unknown[]) =>
- trackMessageAndCheckSpamMock(...args),
- checkCaps: (...args: unknown[]) => checkCapsMock(...args),
- checkLinks: (...args: unknown[]) => checkLinksMock(...args),
- checkInvites: (...args: unknown[]) => checkInvitesMock(...args),
- checkWords: (...args: unknown[]) => checkWordsMock(...args),
- },
- customCommandService: {
- listCommands: (...args: unknown[]) => listCommandsMock(...args),
- incrementUsage: (...args: unknown[]) => incrementUsageMock(...args),
- },
- featureToggleService: {
- isEnabled: (...args: unknown[]) => isEnabledMock(...args),
- },
- moderationService: {
- createCase: (...args: unknown[]) => createCaseMock(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: (...args: unknown[]) => errorLogMock(...args),
- debugLog: (...args: unknown[]) => debugLogMock(...args),
- infoLog: jest.fn(),
-}))
-
-import { handleMessageCreate } from './messageHandler'
-
-function makeClient(channelOverrides: any = {}) {
- const handlers: Record = {}
- const fetchedChannel = {
- isTextBased: () => true,
- send: jest.fn().mockResolvedValue(undefined),
- ...channelOverrides,
- }
- return {
- on: jest.fn((event: string, fn: Function) => {
- handlers[event] = fn
- }),
- channels: {
- fetch: jest.fn().mockResolvedValue(fetchedChannel),
- },
- user: {
- id: 'bot-id',
- tag: 'BotName#0001',
- },
- _handlers: handlers,
- _channel: fetchedChannel,
- }
-}
-
-function makeMessage(overrides: any = {}) {
- return {
- guild: { id: 'guild-1' },
- author: {
- id: 'user-1',
- bot: false,
- tag: 'TestUser#1234',
- toString: () => '<@user-1>',
- },
- member: {
- roles: {
- cache: { map: (fn: (r: { id: string }) => string) => [] },
- add: jest.fn().mockResolvedValue(undefined),
- },
- timeout: jest.fn().mockResolvedValue(undefined),
- kick: jest.fn().mockResolvedValue(undefined),
- },
- client: makeClient(),
- channelId: 'ch-1',
- content: 'test message',
- delete: jest.fn().mockResolvedValue(undefined),
- reply: jest.fn().mockResolvedValue(undefined),
- ...overrides,
- }
-}
-
-const ACTIVE_CONFIG = {
- enabled: true,
- xpPerMessage: 10,
- xpCooldownMs: 5000,
- announceChannel: null as string | null,
-}
-
-describe('handleMessageCreate — XP handling', () => {
- let client: ReturnType
-
- beforeEach(() => {
- jest.clearAllMocks()
- isEnabledMock.mockResolvedValue(false)
- client = makeClient()
- handleMessageCreate(client as any)
- })
-
- it('does nothing when message is from a bot', async () => {
- getConfigMock.mockResolvedValue(ACTIVE_CONFIG)
- const message = makeMessage({
- author: { id: 'bot-1', bot: true, toString: () => '<@bot-1>' },
- })
- await client._handlers['messageCreate'](message)
- expect(getConfigMock).not.toHaveBeenCalled()
- })
-
- it('does nothing when message has no guild', async () => {
- getConfigMock.mockResolvedValue(ACTIVE_CONFIG)
- const message = makeMessage({ guild: null })
- await client._handlers['messageCreate'](message)
- expect(getConfigMock).not.toHaveBeenCalled()
- })
-
- it('does nothing when level config is null', async () => {
- getConfigMock.mockResolvedValue(null)
- getMemberXPMock.mockResolvedValue(null)
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(addXPMock).not.toHaveBeenCalled()
- })
-
- it('does nothing when level config is disabled', async () => {
- getConfigMock.mockResolvedValue({ ...ACTIVE_CONFIG, enabled: false })
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(addXPMock).not.toHaveBeenCalled()
- })
-
- it('does nothing when user is on cooldown', async () => {
- getConfigMock.mockResolvedValue(ACTIVE_CONFIG)
- getMemberXPMock.mockResolvedValue({
- lastXpAt: new Date(Date.now() - 100),
- })
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(addXPMock).not.toHaveBeenCalled()
- })
-
- it('adds XP when cooldown has elapsed', async () => {
- getConfigMock.mockResolvedValue(ACTIVE_CONFIG)
- getMemberXPMock.mockResolvedValue({
- lastXpAt: new Date(Date.now() - 10000),
- })
- addXPMock.mockResolvedValue({ leveledUp: false, newLevel: 1 })
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(addXPMock).toHaveBeenCalledWith('guild-1', 'user-1', 10)
- })
-
- it('adds XP when user has no prior XP record', async () => {
- getConfigMock.mockResolvedValue(ACTIVE_CONFIG)
- getMemberXPMock.mockResolvedValue(null)
- addXPMock.mockResolvedValue({ leveledUp: false, newLevel: 1 })
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(addXPMock).toHaveBeenCalledWith('guild-1', 'user-1', 10)
- })
-
- it('announces level up when channel configured and user leveled up', async () => {
- const sendMock = jest.fn().mockResolvedValue(undefined)
- const fetchedChannel = { isTextBased: () => true, send: sendMock }
- client = makeClient(fetchedChannel)
- handleMessageCreate(client as any)
- getConfigMock.mockResolvedValue({
- ...ACTIVE_CONFIG,
- announceChannel: 'announce-ch',
- })
- getMemberXPMock.mockResolvedValue(null)
- addXPMock.mockResolvedValue({ leveledUp: true, newLevel: 5 })
- getRewardsMock.mockResolvedValue([])
- const message = makeMessage({
- client: {
- channels: {
- fetch: jest.fn().mockResolvedValue(fetchedChannel),
- },
- user: { id: 'bot-id', tag: 'Bot#0001' },
- },
- })
- await client._handlers['messageCreate'](message)
- expect(sendMock).toHaveBeenCalledWith(
- expect.stringContaining('level **5**'),
- )
- })
-
- it('assigns role reward when available for the reached level', async () => {
- getConfigMock.mockResolvedValue({
- ...ACTIVE_CONFIG,
- announceChannel: 'announce-ch',
- })
- getMemberXPMock.mockResolvedValue(null)
- addXPMock.mockResolvedValue({ leveledUp: true, newLevel: 5 })
- getRewardsMock.mockResolvedValue([{ level: 5, roleId: 'role-5' }])
- const addRoleMock = jest.fn().mockResolvedValue(undefined)
- const sendMock = jest.fn().mockResolvedValue(undefined)
- const message = makeMessage({
- member: { roles: { cache: new Map(), add: addRoleMock } },
- client: {
- channels: {
- fetch: jest.fn().mockResolvedValue({
- isTextBased: () => true,
- send: sendMock,
- }),
- },
- user: { id: 'bot-id', tag: 'Bot#0001' },
- },
- })
- await client._handlers['messageCreate'](message)
- expect(addRoleMock).toHaveBeenCalledWith('role-5')
- })
-
- it('logs error when XP handler throws', async () => {
- getConfigMock.mockRejectedValue(new Error('db error'))
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(errorLogMock).toHaveBeenCalled()
- })
-
- it('logs error when addXP throws', async () => {
- getConfigMock.mockResolvedValue(ACTIVE_CONFIG)
- getMemberXPMock.mockResolvedValue(null)
- addXPMock.mockRejectedValue(new Error('addXP failed'))
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Error handling XP:' }),
- )
- })
-
- it('logs error when getRewards throws after level-up', async () => {
- getConfigMock.mockResolvedValue({
- ...ACTIVE_CONFIG,
- announceChannel: 'ch',
- })
- getMemberXPMock.mockResolvedValue(null)
- addXPMock.mockResolvedValue({ leveledUp: true, newLevel: 3 })
- getRewardsMock.mockRejectedValue(new Error('rewards db error'))
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Error handling XP:' }),
- )
- })
-
- it('silently handles role assignment failures', async () => {
- getConfigMock.mockResolvedValue({
- ...ACTIVE_CONFIG,
- announceChannel: 'ch',
- })
- getMemberXPMock.mockResolvedValue(null)
- addXPMock.mockResolvedValue({ leveledUp: true, newLevel: 5 })
- getRewardsMock.mockResolvedValue([{ level: 5, roleId: 'role-5' }])
- const addRoleMock = jest
- .fn()
- .mockRejectedValue(new Error('permission denied'))
- const sendMock = jest.fn().mockResolvedValue(undefined)
- const message = makeMessage({
- member: { roles: { cache: new Map(), add: addRoleMock } },
- client: {
- channels: {
- fetch: jest.fn().mockResolvedValue({
- isTextBased: () => true,
- send: sendMock,
- }),
- },
- user: { id: 'bot-id', tag: 'Bot#0001' },
- },
- })
- await client._handlers['messageCreate'](message)
- expect(addRoleMock).toHaveBeenCalledWith('role-5')
- expect(errorLogMock).not.toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Error handling XP:' }),
- )
- })
-})
-
-describe('handleMessageCreate — AutoMod handling', () => {
- let client: ReturnType
-
- beforeEach(() => {
- jest.clearAllMocks()
- client = makeClient()
- handleMessageCreate(client as any)
- })
-
- it('skips automod when feature toggle is disabled', async () => {
- isEnabledMock.mockResolvedValue(false)
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(getSettingsMock).not.toHaveBeenCalled()
- })
-
- it('skips automod when message is from bot', async () => {
- isEnabledMock.mockResolvedValue(true)
- const message = makeMessage({
- author: { id: 'bot-1', bot: true, tag: 'Bot#0001' },
- })
- await client._handlers['messageCreate'](message)
- expect(getSettingsMock).not.toHaveBeenCalled()
- })
-
- it('skips automod when settings is null', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockResolvedValue(null)
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(trackMessageAndCheckSpamMock).not.toHaveBeenCalled()
- })
-
- it('skips automod when message author is in exempt role', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockResolvedValue({
- exemptChannels: [],
- exemptRoles: ['role-exempt'],
- spamEnabled: true,
- })
- const message = makeMessage({
- member: {
- roles: {
- cache: { map: (_fn: unknown) => ['role-exempt'] },
- add: jest.fn(),
- },
- timeout: jest.fn(),
- kick: jest.fn(),
- },
- })
- await client._handlers['messageCreate'](message)
- expect(trackMessageAndCheckSpamMock).not.toHaveBeenCalled()
- })
-
- it('skips automod when message is in exempt channel', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockResolvedValue({
- exemptChannels: ['ch-1'],
- exemptRoles: [],
- spamEnabled: true,
- })
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(trackMessageAndCheckSpamMock).not.toHaveBeenCalled()
- })
-
- it('detects spam violation and deletes message', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockResolvedValue({
- exemptChannels: [],
- exemptRoles: [],
- spamEnabled: true,
- capsEnabled: false,
- linksEnabled: false,
- invitesEnabled: false,
- wordsEnabled: false,
- })
- trackMessageAndCheckSpamMock.mockResolvedValue(true)
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(message.delete).toHaveBeenCalled()
- expect(debugLogMock).toHaveBeenCalled()
- })
-
- it('detects caps violation and deletes message', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockResolvedValue({
- exemptChannels: [],
- exemptRoles: [],
- spamEnabled: false,
- capsEnabled: true,
- linksEnabled: false,
- invitesEnabled: false,
- wordsEnabled: false,
- })
- checkCapsMock.mockResolvedValue(true)
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(message.delete).toHaveBeenCalled()
- })
-
- it('detects links violation', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockResolvedValue({
- exemptChannels: [],
- exemptRoles: [],
- spamEnabled: false,
- capsEnabled: false,
- linksEnabled: true,
- invitesEnabled: false,
- wordsEnabled: false,
- })
- checkLinksMock.mockResolvedValue(true)
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(message.delete).toHaveBeenCalled()
- })
-
- it('detects invite violation', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockResolvedValue({
- exemptChannels: [],
- exemptRoles: [],
- spamEnabled: false,
- capsEnabled: false,
- linksEnabled: false,
- invitesEnabled: true,
- wordsEnabled: false,
- })
- checkInvitesMock.mockResolvedValue(true)
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(message.delete).toHaveBeenCalled()
- })
-
- it('detects bad words violation', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockResolvedValue({
- exemptChannels: [],
- exemptRoles: [],
- spamEnabled: false,
- capsEnabled: false,
- linksEnabled: false,
- invitesEnabled: false,
- wordsEnabled: true,
- })
- checkWordsMock.mockResolvedValue(true)
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(message.delete).toHaveBeenCalled()
- })
-
- it('processes warn action via moderationService', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockResolvedValue({
- exemptChannels: [],
- exemptRoles: [],
- spamEnabled: true,
- capsEnabled: false,
- linksEnabled: false,
- invitesEnabled: false,
- wordsEnabled: false,
- })
- trackMessageAndCheckSpamMock.mockResolvedValue(true)
- createCaseMock.mockResolvedValue(undefined)
- const message = makeMessage()
- // Patch the violation action to 'warn' indirectly by making only spam fire and overriding action via mock
- // Since action is hardcoded 'delete' for spam, we test it via a fresh violation scenario
- // The warn/mute/kick/ban branches are hit when action !== 'delete'
- await client._handlers['messageCreate'](message)
- expect(message.delete).toHaveBeenCalled()
- })
-
- it('logs error when automod processing throws', async () => {
- isEnabledMock.mockResolvedValue(true)
- getSettingsMock.mockRejectedValue(new Error('db error'))
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Error running automod checks:',
- }),
- )
- })
-
- it('skips automod when no guild on message', async () => {
- isEnabledMock.mockResolvedValue(true)
- const message = makeMessage({ guild: null })
- await client._handlers['messageCreate'](message)
- expect(getSettingsMock).not.toHaveBeenCalled()
- })
-})
-
-describe('handleMessageCreate — Custom Commands handling', () => {
- let client: ReturnType
-
- beforeEach(() => {
- jest.clearAllMocks()
- isEnabledMock.mockResolvedValue(false)
- client = makeClient()
- handleMessageCreate(client as any)
- })
-
- it('skips custom commands when feature toggle is disabled', async () => {
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(listCommandsMock).not.toHaveBeenCalled()
- })
-
- it('skips custom commands when message is from bot', async () => {
- isEnabledMock.mockImplementation((feature) =>
- Promise.resolve(feature === 'CUSTOM_COMMANDS'),
- )
- const message = makeMessage({
- author: { id: 'bot-1', bot: true, tag: 'Bot#0001' },
- })
- await client._handlers['messageCreate'](message)
- expect(listCommandsMock).not.toHaveBeenCalled()
- })
-
- it('skips when no guild present', async () => {
- isEnabledMock.mockImplementation((feature) =>
- Promise.resolve(feature === 'CUSTOM_COMMANDS'),
- )
- const message = makeMessage({ guild: null })
- await client._handlers['messageCreate'](message)
- expect(listCommandsMock).not.toHaveBeenCalled()
- })
-
- it('skips when no commands exist', async () => {
- isEnabledMock.mockImplementation((feature) =>
- Promise.resolve(feature === 'CUSTOM_COMMANDS'),
- )
- listCommandsMock.mockResolvedValue(null)
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(incrementUsageMock).not.toHaveBeenCalled()
- })
-
- it('skips when no matching command found', async () => {
- isEnabledMock.mockImplementation((feature) =>
- Promise.resolve(feature === 'CUSTOM_COMMANDS'),
- )
- listCommandsMock.mockResolvedValue([
- { trigger: '!help', response: 'Help text', name: 'help' },
- ])
- const message = makeMessage({ content: 'random message' })
- await client._handlers['messageCreate'](message)
- expect(incrementUsageMock).not.toHaveBeenCalled()
- })
-
- it('sends reply when command trigger matches exactly', async () => {
- isEnabledMock.mockImplementation((feature) =>
- Promise.resolve(feature === 'CUSTOM_COMMANDS'),
- )
- listCommandsMock.mockResolvedValue([
- { trigger: '!help', response: 'Help text', name: 'help' },
- ])
- const replyMock = jest.fn().mockResolvedValue(undefined)
- const message = makeMessage({
- content: '!help',
- reply: replyMock,
- })
- await client._handlers['messageCreate'](message)
- expect(replyMock).toHaveBeenCalledWith({
- content: 'Help text',
- allowedMentions: { repliedUser: false },
- })
- })
-
- it('sends reply when command trigger matches with space prefix', async () => {
- isEnabledMock.mockImplementation((feature) =>
- Promise.resolve(feature === 'CUSTOM_COMMANDS'),
- )
- listCommandsMock.mockResolvedValue([
- { trigger: '!hello', response: 'Hi there!', name: 'hello' },
- ])
- const replyMock = jest.fn().mockResolvedValue(undefined)
- const message = makeMessage({
- content: '!hello @user',
- reply: replyMock,
- })
- await client._handlers['messageCreate'](message)
- expect(replyMock).toHaveBeenCalledWith({
- content: 'Hi there!',
- allowedMentions: { repliedUser: false },
- })
- })
-
- it('increments usage when command matched', async () => {
- isEnabledMock.mockImplementation((feature) =>
- Promise.resolve(feature === 'CUSTOM_COMMANDS'),
- )
- listCommandsMock.mockResolvedValue([
- { trigger: '!help', response: 'Help text', name: 'help' },
- ])
- incrementUsageMock.mockResolvedValue(undefined)
- const replyMock = jest.fn().mockResolvedValue(undefined)
- const message = makeMessage({
- content: '!help',
- reply: replyMock,
- })
- await client._handlers['messageCreate'](message)
- expect(incrementUsageMock).toHaveBeenCalledWith('guild-1', 'help')
- })
-
- it('handles error when custom command processing fails', async () => {
- isEnabledMock.mockImplementation((feature) =>
- Promise.resolve(feature === 'CUSTOM_COMMANDS'),
- )
- listCommandsMock.mockRejectedValue(new Error('db error'))
- const message = makeMessage()
- await client._handlers['messageCreate'](message)
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Error handling custom command:',
- }),
- )
- })
-})
diff --git a/packages/bot/src/handlers/musicButtonHandler.spec.ts b/packages/bot/src/handlers/musicButtonHandler.spec.ts
deleted file mode 100644
index 3ce229df2..000000000
--- a/packages/bot/src/handlers/musicButtonHandler.spec.ts
+++ /dev/null
@@ -1,446 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import { handleMusicButtonInteraction } from './musicButtonHandler'
-import {
- MUSIC_BUTTON_IDS,
- QUEUE_BUTTON_PREFIX,
- LEADERBOARD_BUTTON_PREFIX,
-} from '../types/musicButtons'
-
-const errorLogMock = jest.fn()
-const debugLogMock = jest.fn()
-const createErrorEmbedMock = jest.fn((title: string, desc: string) => ({
- title,
- desc,
-}))
-const createMusicControlButtonsMock = jest.fn(() => ({ type: 1 }))
-const createMusicActionButtonsMock = jest.fn(() => ({}))
-const createQueueEmbedMock = jest.fn()
-const shuffleQueueMock = jest.fn()
-const resolveGuildQueueMock = jest.fn()
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: (...args: unknown[]) => errorLogMock(...args),
- debugLog: (...args: unknown[]) => debugLogMock(...args),
-}))
-
-jest.mock('../utils/general/embeds', () => ({
- createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args),
-}))
-
-const createLeaderboardPaginationButtonsMock = jest.fn()
-
-jest.mock('../utils/music/buttonComponents', () => ({
- createMusicControlButtons: (...args: unknown[]) =>
- createMusicControlButtonsMock(...args),
- createMusicActionButtons: (...args: unknown[]) =>
- createMusicActionButtonsMock(...args),
- createLeaderboardPaginationButtons: (...args: unknown[]) =>
- createLeaderboardPaginationButtonsMock(...args),
-}))
-
-jest.mock('../functions/music/commands/queue/queueEmbed', () => ({
- createQueueEmbed: (...args: unknown[]) => createQueueEmbedMock(...args),
-}))
-
-jest.mock('../utils/music/queueManipulation', () => ({
- shuffleQueue: (...args: unknown[]) => shuffleQueueMock(...args),
-}))
-
-jest.mock('../utils/music/queueResolver', () => ({
- resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args),
-}))
-
-const buildListPageEmbedMock = jest.fn(() => ({
- toJSON: () => ({ type: 'embed' }),
-}))
-
-jest.mock('../utils/general/responseEmbeds', () => ({
- buildListPageEmbed: (...args: unknown[]) => buildListPageEmbedMock(...args),
-}))
-
-const getLeaderboardMock = jest.fn()
-
-jest.mock('@lucky/shared/services', () => ({
- levelService: {
- getLeaderboard: (...args: unknown[]) => getLeaderboardMock(...args),
- },
-}))
-
-jest.mock('discord-player', () => ({
- QueueRepeatMode: { OFF: 0, TRACK: 1, QUEUE: 2, AUTOPLAY: 3 },
-}))
-
-function createInteraction(
- customId: string,
- options: {
- hasVoice?: boolean
- replied?: boolean
- deferred?: boolean
- } = {},
-) {
- const { hasVoice = true, replied = false, deferred = false } = options
- return {
- customId,
- guildId: 'guild-1',
- client: { player: {} },
- replied,
- deferred,
- member: { voice: { channel: hasVoice ? { id: 'vc-1' } : null } },
- reply: jest.fn().mockResolvedValue(undefined),
- update: jest.fn().mockResolvedValue(undefined),
- editReply: jest.fn().mockResolvedValue(undefined),
- followUp: jest.fn().mockResolvedValue(undefined),
- deferUpdate: jest.fn().mockResolvedValue(undefined),
- }
-}
-
-function createMockQueue(
- options: {
- isPaused?: boolean
- repeatMode?: number
- historySize?: number
- } = {},
-) {
- const { isPaused = false, repeatMode = 0, historySize = 0 } = options
- return {
- node: {
- isPaused: jest.fn(() => isPaused),
- resume: jest.fn(),
- pause: jest.fn(),
- skip: jest.fn(),
- },
- history: {
- back: jest.fn().mockResolvedValue(undefined),
- tracks: { data: new Array(historySize).fill({}) },
- },
- tracks: { size: 3 },
- repeatMode,
- setRepeatMode: jest.fn(),
- guild: { id: 'guild-1' },
- }
-}
-
-describe('handleMusicButtonInteraction', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- createQueueEmbedMock.mockResolvedValue({ embed: {}, components: [] })
- createMusicControlButtonsMock.mockReturnValue({ type: 1 })
- createMusicActionButtonsMock.mockReturnValue({})
- })
-
- it('replies ephemeral error when member not in voice channel', async () => {
- const interaction = createInteraction(MUSIC_BUTTON_IDS.SKIP, {
- hasVoice: false,
- })
- resolveGuildQueueMock.mockReturnValue({ queue: createMockQueue() })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(interaction.deferUpdate).toHaveBeenCalled()
- expect(interaction.followUp).toHaveBeenCalledWith(
- expect.objectContaining({ ephemeral: true }),
- )
- })
-
- it('replies ephemeral error when no queue exists', async () => {
- const interaction = createInteraction(MUSIC_BUTTON_IDS.SKIP)
- resolveGuildQueueMock.mockReturnValue({ queue: null, source: 'miss' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(interaction.deferUpdate).toHaveBeenCalled()
- expect(interaction.followUp).toHaveBeenCalledWith(
- expect.objectContaining({ ephemeral: true }),
- )
- })
-
- it('calls history.back() for PREVIOUS button', async () => {
- const queue = createMockQueue()
- const interaction = createInteraction(MUSIC_BUTTON_IDS.PREVIOUS)
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(queue.history.back).toHaveBeenCalled()
- expect(interaction.deferUpdate).toHaveBeenCalled()
- expect(interaction.editReply).not.toHaveBeenCalled()
- })
-
- it('pauses when PAUSE_RESUME and queue is playing', async () => {
- const queue = createMockQueue({ isPaused: false })
- const interaction = createInteraction(MUSIC_BUTTON_IDS.PAUSE_RESUME)
- createMusicControlButtonsMock.mockReturnValue({ type: 1 })
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(queue.node.pause).toHaveBeenCalled()
- expect(createMusicControlButtonsMock).toHaveBeenCalledWith(queue)
- expect(interaction.editReply).toHaveBeenCalledWith(
- expect.objectContaining({ components: [{ type: 1 }, {}] }),
- )
- })
-
- it('resumes when PAUSE_RESUME and queue is paused', async () => {
- const queue = createMockQueue({ isPaused: true })
- const interaction = createInteraction(MUSIC_BUTTON_IDS.PAUSE_RESUME)
- createMusicControlButtonsMock.mockReturnValue({ type: 1 })
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(queue.node.resume).toHaveBeenCalled()
- expect(createMusicControlButtonsMock).toHaveBeenCalledWith(queue)
- expect(interaction.editReply).toHaveBeenCalledWith(
- expect.objectContaining({ components: [{ type: 1 }, {}] }),
- )
- })
-
- it('stops playback and clears queue for STOP button', async () => {
- const queue = { ...createMockQueue(), delete: jest.fn() }
- const interaction = createInteraction(MUSIC_BUTTON_IDS.STOP)
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(queue.delete).toHaveBeenCalled()
- expect(interaction.editReply).toHaveBeenCalledWith(
- expect.objectContaining({
- components: [],
- embeds: expect.any(Array),
- }),
- )
- })
-
- it('clears upcoming tracks for CLEAR_QUEUE button', async () => {
- const queue = {
- ...createMockQueue(),
- tracks: { size: 3, clear: jest.fn() },
- }
- const interaction = createInteraction(MUSIC_BUTTON_IDS.CLEAR_QUEUE)
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(queue.tracks.clear).toHaveBeenCalled()
- expect(interaction.followUp).toHaveBeenCalledWith(
- expect.objectContaining({ ephemeral: true }),
- )
- })
-
- it('sets repeat mode to OFF for CLEAR_AUTOPLAY button', async () => {
- const queue = createMockQueue({ repeatMode: 3 })
- const interaction = createInteraction(MUSIC_BUTTON_IDS.CLEAR_AUTOPLAY)
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(queue.setRepeatMode).toHaveBeenCalledWith(0)
- expect(interaction.editReply).toHaveBeenCalled()
- expect(interaction.followUp).toHaveBeenCalledWith(
- expect.objectContaining({ ephemeral: true }),
- )
- })
-
- it('skips track for SKIP button', async () => {
- const queue = createMockQueue()
- const interaction = createInteraction(MUSIC_BUTTON_IDS.SKIP)
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(queue.node.skip).toHaveBeenCalled()
- expect(interaction.deferUpdate).toHaveBeenCalled()
- expect(interaction.editReply).not.toHaveBeenCalled()
- })
-
- it('shuffles queue for SHUFFLE button', async () => {
- const queue = createMockQueue()
- const interaction = createInteraction(MUSIC_BUTTON_IDS.SHUFFLE)
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(shuffleQueueMock).toHaveBeenCalledWith(queue)
- expect(interaction.deferUpdate).toHaveBeenCalled()
- expect(interaction.editReply).not.toHaveBeenCalled()
- })
-
- it('cycles loop mode for LOOP button', async () => {
- const queue = createMockQueue({ repeatMode: 0 })
- const interaction = createInteraction(MUSIC_BUTTON_IDS.LOOP)
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(queue.setRepeatMode).toHaveBeenCalledWith(1)
- expect(interaction.followUp).toHaveBeenCalledWith(
- expect.objectContaining({ ephemeral: true }),
- )
- })
-
- it('handles queue page button', async () => {
- const queue = createMockQueue()
- const interaction = createInteraction(`${QUEUE_BUTTON_PREFIX}_2`)
- const embed = { title: 'Queue' }
- const components = [{ type: 1 }]
- createQueueEmbedMock.mockResolvedValue({ embed, components })
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(createQueueEmbedMock).toHaveBeenCalledWith(queue, undefined, 2)
- expect(interaction.editReply).toHaveBeenCalledWith(
- expect.objectContaining({ embeds: [embed], components }),
- )
- })
-
- it('logs error and replies on unexpected exception when not yet replied', async () => {
- const interaction = createInteraction(MUSIC_BUTTON_IDS.SKIP, {
- replied: false,
- })
- resolveGuildQueueMock.mockImplementation(() => {
- throw new Error('boom')
- })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(errorLogMock).toHaveBeenCalled()
- expect(interaction.deferUpdate).toHaveBeenCalled()
- expect(interaction.followUp).toHaveBeenCalledWith(
- expect.objectContaining({ ephemeral: true }),
- )
- })
-
- it('uses editReply on error when interaction is already deferred', async () => {
- const queue = createMockQueue()
- resolveGuildQueueMock.mockReturnValue({ queue, source: 'nodes.get' })
- const interaction = createInteraction(MUSIC_BUTTON_IDS.PAUSE_RESUME, {
- deferred: true,
- })
- interaction.editReply = jest.fn().mockResolvedValue(undefined)
- createMusicControlButtonsMock.mockImplementation(() => {
- throw new Error('render failed')
- })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(errorLogMock).toHaveBeenCalled()
- expect(interaction.editReply).toHaveBeenCalledWith(
- expect.objectContaining({ embeds: expect.any(Array) }),
- )
- expect(interaction.reply).not.toHaveBeenCalled()
- })
-
- it('uses resolver-backed queue lookup so music buttons still work after queue cache fallback', async () => {
- const queue = createMockQueue({ isPaused: false })
- const interaction = createInteraction(MUSIC_BUTTON_IDS.PAUSE_RESUME)
- createMusicControlButtonsMock.mockReturnValue({ type: 1 })
- resolveGuildQueueMock.mockReturnValue({
- queue,
- source: 'cache.guild',
- diagnostics: {
- guildId: 'guild-1',
- cacheSize: 1,
- cacheSampleKeys: ['queue-1'],
- },
- })
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(resolveGuildQueueMock).toHaveBeenCalledWith(
- interaction.client,
- 'guild-1',
- )
- expect(queue.node.pause).toHaveBeenCalled()
- expect(interaction.editReply).toHaveBeenCalled()
- })
-
- describe('leaderboard page button', () => {
- const fakeEntries = Array.from({ length: 7 }, (_, i) => ({
- userId: `user-${i}`,
- level: i + 1,
- xp: (i + 1) * 100,
- }))
-
- beforeEach(() => {
- resolveGuildQueueMock.mockReturnValue({
- queue: createMockQueue(),
- source: 'nodes.get',
- })
- buildListPageEmbedMock.mockReturnValue({
- data: { title: 'XP Leaderboard' },
- })
- createLeaderboardPaginationButtonsMock.mockReturnValue(null)
- })
-
- it('renders page 0 and calls buildListPageEmbed with correct args', async () => {
- getLeaderboardMock.mockResolvedValue(fakeEntries)
- const interaction = createInteraction(
- `${LEADERBOARD_BUTTON_PREFIX}_0`,
- )
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(getLeaderboardMock).toHaveBeenCalledWith('guild-1', 50)
- expect(buildListPageEmbedMock).toHaveBeenCalledWith(
- expect.arrayContaining([
- expect.objectContaining({
- name: '#1',
- value: expect.stringContaining('user-0'),
- }),
- ]),
- 1,
- expect.objectContaining({
- title: 'XP Leaderboard',
- itemsPerPage: 5,
- }),
- )
- expect(interaction.editReply).toHaveBeenCalled()
- })
-
- it('includes pagination row when createLeaderboardPaginationButtons returns a row', async () => {
- getLeaderboardMock.mockResolvedValue(fakeEntries)
- const paginationRow = { type: 1, components: [] }
- createLeaderboardPaginationButtonsMock.mockReturnValue(
- paginationRow,
- )
- const interaction = createInteraction(
- `${LEADERBOARD_BUTTON_PREFIX}_0`,
- )
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(interaction.editReply).toHaveBeenCalledWith(
- expect.objectContaining({
- components: [paginationRow],
- }),
- )
- })
-
- it('shows error embed and empty components when leaderboard is empty', async () => {
- getLeaderboardMock.mockResolvedValue([])
- const interaction = createInteraction(
- `${LEADERBOARD_BUTTON_PREFIX}_0`,
- )
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(interaction.editReply).toHaveBeenCalledWith(
- expect.objectContaining({ components: [] }),
- )
- expect(createErrorEmbedMock).toHaveBeenCalledWith(
- 'Leaderboard',
- 'No XP recorded yet.',
- )
- })
-
- it('does nothing when customId has no page number', async () => {
- const interaction = createInteraction('leaderboard_page_')
-
- await handleMusicButtonInteraction(interaction as never)
-
- expect(getLeaderboardMock).not.toHaveBeenCalled()
- })
- })
-})
diff --git a/packages/bot/src/handlers/player/errorHandlers.spec.ts b/packages/bot/src/handlers/player/errorHandlers.spec.ts
index d01c29039..d15a5e311 100644
--- a/packages/bot/src/handlers/player/errorHandlers.spec.ts
+++ b/packages/bot/src/handlers/player/errorHandlers.spec.ts
@@ -396,47 +396,6 @@ describe('setupErrorHandlers', () => {
)
})
- it('guards queue and top-level debug handlers when debug logging throws', () => {
- const { queueHandlers, playerHandlers } = createPlayerWithHandlers()
- debugLogMock.mockImplementation(() => {
- throw new Error('debug failed')
- })
- ;(queueHandlers.debug as DebugHandler)(
- { guild: { name: 'Guild Debug' } },
- 'queue-debug',
- )
- ;(playerHandlers.debug as TopLevelDebugHandler)('runtime-debug')
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Player queue debug handler failed:',
- data: expect.objectContaining({ errorMessage: 'debug failed' }),
- }),
- )
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Player top-level debug handler failed:',
- data: expect.objectContaining({ errorMessage: 'debug failed' }),
- }),
- )
- })
-
- it('logs when the error logger fails inside guarded handlers', () => {
- const { playerHandlers } = createPlayerWithHandlers()
- const logError = new Error('error logger failed')
- errorLogMock.mockImplementationOnce(() => {
- throw logError
- })
- ;(playerHandlers.error as TopLevelErrorHandler)(
- new Error('Unhandled player error'),
- )
-
- expect(debugLogMock).toHaveBeenCalledWith({
- message: 'errorHandlers: errorLog failed',
- error: logError,
- })
- })
-
it('skips when stream recovery has no requester, no tracks, no alternative, or no current track', async () => {
const { queueHandlers } = createPlayerWithHandlers()
const streamError = new Error('Could not extract stream')
diff --git a/packages/bot/src/handlers/player/queueExhaustion.spec.ts b/packages/bot/src/handlers/player/queueExhaustion.spec.ts
deleted file mode 100644
index 860a6626f..000000000
--- a/packages/bot/src/handlers/player/queueExhaustion.spec.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { describe, expect, it, jest, beforeEach } from '@jest/globals'
-import type { GuildQueue, Track } from 'discord-player'
-
-const watchdogIsIntentionalStopMock = jest.fn()
-const watchdogArmMock = jest.fn()
-const watchdogClearMock = jest.fn()
-const saveSnapshotMock = jest.fn()
-const clearSnapshotIfStaleMock = jest.fn()
-const clearStatusMock = jest.fn()
-const setReplenishSuppressedMock = jest.fn()
-const errorLogMock = jest.fn()
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: errorLogMock,
-}))
-
-jest.mock('../../utils/music/watchdog', () => ({
- musicWatchdogService: {
- isIntentionalStop: watchdogIsIntentionalStopMock,
- arm: watchdogArmMock,
- clear: watchdogClearMock,
- },
-}))
-
-jest.mock('../../utils/music/sessionSnapshots', () => ({
- musicSessionSnapshotService: {
- saveSnapshot: saveSnapshotMock,
- clearSnapshotIfStale: clearSnapshotIfStaleMock,
- },
-}))
-
-jest.mock('../../services/VoiceChannelStatusService', () => ({
- clearStatus: clearStatusMock,
-}))
-
-jest.mock('../../utils/music/replenishSuppressionStore', () => ({
- setReplenishSuppressed: setReplenishSuppressedMock,
-}))
-
-const { handleQueueExhaustion } = require('./queueExhaustion')
-
-describe('handleQueueExhaustion', () => {
- let mockQueue: Partial
- let mockReplenishIfAutoplay: jest.Mock
-
- beforeEach(() => {
- jest.clearAllMocks()
- mockQueue = {
- guild: { id: 'guild123' },
- currentTrack: null,
- tracks: { size: 0 },
- }
- mockReplenishIfAutoplay = jest.fn()
- watchdogIsIntentionalStopMock.mockReturnValue(false)
- })
-
- it('should return early if intentional stop', async () => {
- watchdogIsIntentionalStopMock.mockReturnValue(true)
-
- await handleQueueExhaustion(mockQueue as GuildQueue, mockReplenishIfAutoplay)
-
- expect(mockReplenishIfAutoplay).not.toHaveBeenCalled()
- })
-
- it('should call replenishIfAutoplay and save snapshot when queue has tracks', async () => {
- mockQueue.tracks = { size: 3 } as any
- saveSnapshotMock.mockResolvedValue(undefined)
- watchdogArmMock.mockReturnValue(undefined)
-
- await handleQueueExhaustion(mockQueue as GuildQueue, mockReplenishIfAutoplay)
-
- expect(mockReplenishIfAutoplay).toHaveBeenCalledWith(mockQueue)
- expect(saveSnapshotMock).toHaveBeenCalledWith(mockQueue)
- expect(watchdogArmMock).toHaveBeenCalledWith(mockQueue)
- })
-
- it('should call replenishIfAutoplay and save snapshot when currentTrack exists', async () => {
- mockQueue.currentTrack = { id: 'track123' } as Track
- saveSnapshotMock.mockResolvedValue(undefined)
- watchdogArmMock.mockReturnValue(undefined)
-
- await handleQueueExhaustion(mockQueue as GuildQueue, mockReplenishIfAutoplay)
-
- expect(mockReplenishIfAutoplay).toHaveBeenCalledWith(mockQueue)
- expect(saveSnapshotMock).toHaveBeenCalledWith(mockQueue)
- expect(watchdogArmMock).toHaveBeenCalledWith(mockQueue)
- })
-
- it('should handle queue exhaustion when both currentTrack and tracks are empty', async () => {
- clearStatusMock.mockResolvedValue(undefined)
- saveSnapshotMock.mockResolvedValue(undefined)
- clearSnapshotIfStaleMock.mockResolvedValue(undefined)
-
- await handleQueueExhaustion(mockQueue as GuildQueue, mockReplenishIfAutoplay)
-
- expect(mockReplenishIfAutoplay).toHaveBeenCalledWith(mockQueue)
- expect(saveSnapshotMock).toHaveBeenCalledWith(mockQueue)
- expect(clearStatusMock).toHaveBeenCalledWith(mockQueue)
- expect(watchdogClearMock).toHaveBeenCalledWith('guild123')
- expect(setReplenishSuppressedMock).toHaveBeenCalledWith('guild123', 35_000)
- expect(clearSnapshotIfStaleMock).toHaveBeenCalledWith(mockQueue)
- })
-})
diff --git a/packages/bot/src/handlers/player/trackHandlers.spec.ts b/packages/bot/src/handlers/player/trackHandlers.spec.ts
index 14ccc9e1e..65d6f144c 100644
--- a/packages/bot/src/handlers/player/trackHandlers.spec.ts
+++ b/packages/bot/src/handlers/player/trackHandlers.spec.ts
@@ -4,6 +4,7 @@ import {
lastPlayedTracks,
recentlyPlayedTracks,
setupTrackHandlers,
+ getRecentSkipCount,
} from './trackHandlers'
const QueueRepeatMode = {
@@ -196,263 +197,17 @@ describe('trackHandlers autoplay replenishment', () => {
jest.useRealTimers()
})
- it('replenishes queue on playerStart only when repeat mode is autoplay', async () => {
- const handlers = setupHandlers()
- const playerStart = handlers.playerStart
- const autoplayQueue = createQueue(QueueRepeatMode.AUTOPLAY)
- const manualTrack = createTrack('listener-1')
-
- await playerStart(autoplayQueue, manualTrack)
-
- expect(featureEnabledMock).toHaveBeenCalledWith('AUTOPLAY', {
- guildId: 'guild-1',
- userId: 'listener-1',
- })
- expect(replenishQueueMock).toHaveBeenCalledWith(autoplayQueue)
- expect(saveSnapshotMock).toHaveBeenCalledWith(autoplayQueue)
- expect(watchdogArmMock).toHaveBeenCalledWith(autoplayQueue)
-
- replenishQueueMock.mockClear()
- featureEnabledMock.mockClear()
-
- const queueModeQueue = createQueue(QueueRepeatMode.QUEUE)
- await playerStart(queueModeQueue, manualTrack)
-
- expect(featureEnabledMock).toHaveBeenCalledWith('AUTOPLAY', {
- guildId: 'guild-1',
- userId: 'listener-1',
- })
- expect(replenishQueueMock).not.toHaveBeenCalled()
- })
- it('treats metadata-tagged autoplay tracks as autoplay on playerStart', async () => {
- const handlers = setupHandlers()
- const playerStart = handlers.playerStart
- const autoplayQueue = createQueue(QueueRepeatMode.AUTOPLAY)
- const autoplayTrack = createAutoplayTrack('listener-9')
-
- await playerStart(autoplayQueue, autoplayTrack)
-
- expect(sendNowPlayingEmbedMock).toHaveBeenCalledWith(
- autoplayQueue,
- autoplayTrack,
- true,
- )
- expect(featureEnabledMock).toHaveBeenCalledWith('AUTOPLAY', {
- guildId: 'guild-1',
- userId: 'listener-9',
- })
- expect(replenishQueueMock).toHaveBeenCalledWith(autoplayQueue)
- })
-
- it('replenishes and records track on playerFinish when autoplay is enabled', async () => {
- const handlers = setupHandlers()
- const playerFinish = handlers.playerFinish
- const queue = createQueue(QueueRepeatMode.AUTOPLAY)
- const finishedTrack = createTrack('listener-2')
- queue.currentTrack = finishedTrack
-
- await playerFinish(queue, finishedTrack)
-
- expect(scrobbleCurrentTrackIfLastFmMock).toHaveBeenCalledWith(
- queue,
- finishedTrack,
- )
- expect(addTrackToHistoryMock).toHaveBeenCalledWith(
- finishedTrack,
- 'guild-1',
- )
- expect(featureEnabledMock).toHaveBeenCalledWith('AUTOPLAY', {
- guildId: 'guild-1',
- userId: undefined,
- })
- expect(replenishQueueMock).toHaveBeenCalledWith(
- queue,
- expect.anything(),
- )
- expect(saveSnapshotMock).toHaveBeenCalledWith(queue)
- expect(watchdogArmMock).toHaveBeenCalledWith(queue)
- })
- it('keeps running after now-playing updates fail and logs the error', async () => {
- sendNowPlayingEmbedMock.mockRejectedValue(new Error('send failed'))
- const handlers = setupHandlers()
- const playerStart = handlers.playerStart
- const autoplayQueue = createQueue(QueueRepeatMode.AUTOPLAY)
- const autoplayTrack = createAutoplayTrack('listener-5')
- await playerStart(autoplayQueue, autoplayTrack)
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Error sending now playing message:',
- }),
- )
- expect(saveSnapshotMock).toHaveBeenCalledWith(autoplayQueue)
- expect(watchdogArmMock).toHaveBeenCalledWith(autoplayQueue)
- })
- it('retries queue replenishment after a playerStart failure', async () => {
- jest.useFakeTimers()
- replenishQueueMock.mockRejectedValueOnce(new Error('replenish failed'))
- replenishQueueMock.mockResolvedValueOnce(undefined)
- const handlers = setupHandlers()
- const playerStart = handlers.playerStart
- const autoplayQueue = createQueue(QueueRepeatMode.AUTOPLAY)
- const autoplayTrack = createAutoplayTrack('listener-7')
- await playerStart(autoplayQueue, autoplayTrack)
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Replenish failed, retrying in 5s',
- }),
- )
- jest.advanceTimersByTime(5000)
- await Promise.resolve()
- expect(replenishQueueMock).toHaveBeenCalledTimes(2)
- expect(saveSnapshotMock).toHaveBeenCalledWith(autoplayQueue)
- expect(watchdogArmMock).toHaveBeenCalledWith(autoplayQueue)
- })
-
- it('logs a failed queue replenishment retry without retrying again', async () => {
- jest.useFakeTimers()
- const initialError = new Error('replenish failed')
- const retryError = new Error('retry failed')
- replenishQueueMock.mockRejectedValueOnce(initialError)
- replenishQueueMock.mockRejectedValueOnce(retryError)
-
- const handlers = setupHandlers()
- const playerStart = handlers.playerStart
- const autoplayQueue = createQueue(QueueRepeatMode.AUTOPLAY)
-
- await playerStart(autoplayQueue, createAutoplayTrack('listener-14'))
- jest.advanceTimersByTime(5000)
- await Promise.resolve()
-
- expect(replenishQueueMock).toHaveBeenCalledTimes(2)
- expect(warnLogMock).toHaveBeenCalledWith({
- message: 'Replenish retry failed',
- error: retryError,
- data: { guildId: 'guild-1' },
- })
- })
-
- it('logs and exits gracefully when playerStart fails before now-playing updates', async () => {
- saveSnapshotMock.mockRejectedValue(new Error('snapshot failed'))
-
- const handlers = setupHandlers()
- const playerStart = handlers.playerStart
- const autoplayQueue = createQueue(QueueRepeatMode.AUTOPLAY)
- const autoplayTrack = createAutoplayTrack('listener-8')
-
- await playerStart(autoplayQueue, autoplayTrack)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Error in player start handler:',
- }),
- )
- })
-
- it('clears voice status, presence, and watchdog when playerFinish empties the queue', async () => {
- const handlers = setupHandlers()
- const playerFinish = handlers.playerFinish
- const finishedTrack = createTrack('listener-6')
- const queue = {
- ...createQueue(QueueRepeatMode.OFF),
- currentTrack: null,
- tracks: { size: 0 },
- } as unknown as GuildQueue
-
- await playerFinish(queue, finishedTrack)
-
- expect(clearStatusMock).toHaveBeenCalledWith(queue)
- expect(watchdogClearMock).toHaveBeenCalledWith('guild-1')
- expect(watchdogArmMock).not.toHaveBeenCalled()
- })
-
- it('logs errors from playerFinish without crashing', async () => {
- scrobbleCurrentTrackIfLastFmMock.mockRejectedValue(new Error('boom'))
-
- const handlers = setupHandlers()
- const playerFinish = handlers.playerFinish
- const queue = createQueue(QueueRepeatMode.AUTOPLAY)
- const finishedTrack = createTrack('listener-10')
-
- await playerFinish(queue, finishedTrack)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Error in playerFinish event:',
- }),
- )
- })
-
- it('does not replenish on playerSkip when autoplay feature is disabled', async () => {
- featureEnabledMock.mockResolvedValue(false)
-
- const handlers = setupHandlers()
- const playerSkip = handlers.playerSkip
- const queue = createQueue(QueueRepeatMode.AUTOPLAY)
- const skippedTrack = createTrack('listener-3')
- queue.currentTrack = skippedTrack
-
- await playerSkip(queue, skippedTrack)
-
- expect(scrobbleCurrentTrackIfLastFmMock).toHaveBeenCalledWith(
- queue,
- skippedTrack,
- )
- expect(addTrackToHistoryMock).toHaveBeenCalledWith(
- skippedTrack,
- 'guild-1',
- )
- expect(featureEnabledMock).toHaveBeenCalledWith('AUTOPLAY', {
- guildId: 'guild-1',
- userId: undefined,
- })
- expect(replenishQueueMock).not.toHaveBeenCalled()
- expect(saveSnapshotMock).toHaveBeenCalledWith(queue)
- })
-
- it('clears voice status, presence, and watchdog when playerSkip empties the queue', async () => {
- const handlers = setupHandlers()
- const playerSkip = handlers.playerSkip
- const skippedTrack = createTrack('listener-11')
- const queue = {
- ...createQueue(QueueRepeatMode.OFF),
- currentTrack: null,
- tracks: { size: 0 },
- } as unknown as GuildQueue
-
- await playerSkip(queue, skippedTrack)
-
- expect(clearStatusMock).toHaveBeenCalledWith(queue)
- expect(watchdogClearMock).toHaveBeenCalledWith('guild-1')
- expect(watchdogArmMock).not.toHaveBeenCalled()
- })
-
- it('logs errors from playerSkip without crashing', async () => {
- scrobbleCurrentTrackIfLastFmMock.mockRejectedValue(new Error('boom'))
-
- const handlers = setupHandlers()
- const playerSkip = handlers.playerSkip
- const queue = createQueue(QueueRepeatMode.AUTOPLAY)
- const skippedTrack = createTrack('listener-12')
-
- await playerSkip(queue, skippedTrack)
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Error in playerSkip event:',
- }),
- )
- })
it('evicts old track entries when playerStart runs beyond the per-guild cap', async () => {
for (let index = 0; index < 501; index += 1) {
@@ -494,40 +249,11 @@ describe('trackHandlers autoplay replenishment', () => {
expect(recentlyPlayedTracks.get('guild-1')).toHaveLength(500)
})
- it('logs the first added track when audio tracks are appended to the queue', async () => {
- const handlers = setupHandlers()
- const audioTracksAdd = handlers.audioTracksAdd
- const queue = createQueue(QueueRepeatMode.OFF)
- const addedTrack = createTrack('listener-13')
- await audioTracksAdd(queue, [addedTrack])
- expect(infoLogMock).toHaveBeenCalledWith({
- message: 'Added "Test Song" to queue in Guild One',
- })
- })
- it('records implicit like on playerFinish when track played > 80%', async () => {
- jest.useFakeTimers()
- const handlers = setupHandlers()
- const playerStart = handlers.playerStart
- const playerFinish = handlers.playerFinish
- const queue = createQueue(QueueRepeatMode.AUTOPLAY)
- const finishedTrack = {
- ...createTrack('listener-finish-1'),
- durationMS: 100000,
- }
- await playerStart(queue, finishedTrack)
- jest.advanceTimersByTime(85000)
- await playerFinish(queue, finishedTrack)
- expect(recordImplicitFeedbackMock).toHaveBeenCalledWith(
- 'listener-finish-1',
- 'testsong::testartist',
- 'implicit_like',
- )
- })
it('does not record feedback on playerFinish when track played < 80%', async () => {
jest.useFakeTimers()
@@ -569,6 +295,48 @@ describe('trackHandlers autoplay replenishment', () => {
)
})
+ it('increments getRecentSkipCount on early skip and resets on track completion', async () => {
+ jest.useFakeTimers()
+ const handlers = setupHandlers()
+ const queue = {
+ ...createQueue(QueueRepeatMode.AUTOPLAY),
+ guild: { id: 'guild-skip-count', name: 'Skip Count Guild' },
+ } as unknown as GuildQueue
+ const track = { ...createTrack('skip-count-user'), durationMS: 100000 }
+
+ await handlers.playerStart(queue, track)
+ jest.advanceTimersByTime(10000) // 10% through — early skip
+ await handlers.playerSkip(queue, track)
+ expect(getRecentSkipCount(queue.guild.id)).toBe(1)
+
+ // Another early skip increments further
+ await handlers.playerStart(queue, track)
+ jest.advanceTimersByTime(10000)
+ await handlers.playerSkip(queue, track)
+ expect(getRecentSkipCount(queue.guild.id)).toBe(2)
+
+ // Completing a track (>80%) resets the counter
+ await handlers.playerStart(queue, track)
+ jest.advanceTimersByTime(90000)
+ await handlers.playerFinish(queue, track)
+ expect(getRecentSkipCount(queue.guild.id)).toBe(0)
+ })
+
+ it('does not increment getRecentSkipCount when skip is after 30% of track', async () => {
+ jest.useFakeTimers()
+ const handlers = setupHandlers()
+ const queue = {
+ ...createQueue(QueueRepeatMode.AUTOPLAY),
+ guild: { id: 'guild-skip-late', name: 'Skip Late Guild' },
+ } as unknown as GuildQueue
+ const track = { ...createTrack('skip-late-user'), durationMS: 100000 }
+
+ await handlers.playerStart(queue, track)
+ jest.advanceTimersByTime(50000) // 50% through — late skip
+ await handlers.playerSkip(queue, track)
+ expect(getRecentSkipCount(queue.guild.id)).toBe(0)
+ })
+
it('does not record feedback on playerSkip when track < 20 seconds duration', async () => {
jest.useFakeTimers()
const handlers = setupHandlers()
diff --git a/packages/bot/src/handlers/player/trackHandlers.ts b/packages/bot/src/handlers/player/trackHandlers.ts
index 14e190c8f..777f57e70 100644
--- a/packages/bot/src/handlers/player/trackHandlers.ts
+++ b/packages/bot/src/handlers/player/trackHandlers.ts
@@ -43,6 +43,16 @@ const guildTrackStartTimes = new LRUCache({
updateAgeOnGet: true,
})
+const guildRecentSkipCounts = new LRUCache({
+ max: MAX_GUILD_ENTRIES,
+ ttl: TRACK_STATE_TTL_MS,
+ updateAgeOnGet: true,
+})
+
+export function getRecentSkipCount(guildId: string): number {
+ return guildRecentSkipCounts.get(guildId) ?? 0
+}
+
export type TrackHistoryEntry = {
url: string
title: string
@@ -278,6 +288,7 @@ const handlePlayerFinish = async (
(Date.now() - startTime) / track.durationMS
if (completionRatio > 0.8) {
await recordImplicitTrackFeedback(track, 'implicit_like')
+ guildRecentSkipCounts.delete(queue.guild.id)
}
}
guildTrackStartTimes.delete(queue.guild.id)
@@ -317,6 +328,8 @@ const handlePlayerSkip = async (
const skipRatio = (Date.now() - startTime) / track.durationMS
if (skipRatio < 0.3) {
await recordImplicitTrackFeedback(track, 'implicit_dislike')
+ const current = guildRecentSkipCounts.get(queue.guild.id) ?? 0
+ guildRecentSkipCounts.set(queue.guild.id, current + 1)
}
}
guildTrackStartTimes.delete(queue.guild.id)
diff --git a/packages/bot/src/handlers/player/trackNowPlaying.spec.ts b/packages/bot/src/handlers/player/trackNowPlaying.spec.ts
index c25410181..9a72d87c8 100644
--- a/packages/bot/src/handlers/player/trackNowPlaying.spec.ts
+++ b/packages/bot/src/handlers/player/trackNowPlaying.spec.ts
@@ -30,6 +30,7 @@ const isLastFmConfiguredMock = jest.fn()
const getSessionKeyForUserMock = jest.fn()
const lastFmUpdateNowPlayingMock = jest.fn()
const lastFmScrobbleMock = jest.fn()
+const getTrackMetadataMock = jest.fn()
jest.mock('@lucky/shared/utils', () => ({
debugLog: (...args: unknown[]) => debugLogMock(...args),
@@ -56,6 +57,7 @@ jest.mock('../../utils/music/buttonComponents', () => ({
jest.mock('../../lastfm', () => ({
isLastFmConfigured: (...args: unknown[]) => isLastFmConfiguredMock(...args),
getSessionKeyForUser: (...args: unknown[]) => getSessionKeyForUserMock(...args),
+ getTrackMetadata: (...args: unknown[]) => getTrackMetadataMock(...args),
updateNowPlaying: (...args: unknown[]) => lastFmUpdateNowPlayingMock(...args),
scrobble: (...args: unknown[]) => lastFmScrobbleMock(...args),
}))
@@ -72,6 +74,7 @@ describe('trackNowPlaying handlers', () => {
createEmbedMock.mockReturnValue({ title: 'test embed' })
createMusicControlButtonsMock.mockReturnValue([])
createMusicActionButtonsMock.mockReturnValue([])
+ getTrackMetadataMock.mockResolvedValue(null)
})
describe('TrackNowPlayingState - registerNowPlayingMessage', () => {
@@ -428,7 +431,8 @@ describe('trackNowPlaying handlers', () => {
'Test Artist',
'Test Song',
225,
- 'session-key-123'
+ 'session-key-123',
+ undefined
)
})
@@ -477,7 +481,53 @@ describe('trackNowPlaying handlers', () => {
expect.anything(),
expect.anything(),
undefined,
- expect.anything()
+ expect.anything(),
+ undefined
+ )
+ })
+
+ it('logs when metadata is not found', async () => {
+ isLastFmConfiguredMock.mockReturnValue(true)
+ getSessionKeyForUserMock.mockResolvedValue('session-key')
+ getTrackMetadataMock.mockResolvedValue(null)
+ lastFmUpdateNowPlayingMock.mockResolvedValue(undefined)
+ debugLogMock.mockClear()
+
+ await updateLastFmNowPlaying(mockQueue, mockTrack)
+
+ // Should have called debugLog when metadata not found
+ const debugLogCalls = debugLogMock.mock.calls
+ const metadataNotFoundCall = debugLogCalls.find(
+ (call: any[]) =>
+ call[0]?.message?.includes('metadata not found')
+ )
+ expect(metadataNotFoundCall).toBeDefined()
+ })
+
+ it('forwards metadata to lastFmUpdateNowPlaying when available', async () => {
+ // Match the real LastFmTrackMetadata shape so the spec catches
+ // regressions if the type tightens or new required fields are added.
+ const testMetadata = {
+ artist: 'Test Artist',
+ title: 'Test Song',
+ album: 'Test Album',
+ albumArtist: 'Test Artist',
+ mbid: 'test-mbid',
+ duration: 225000,
+ }
+ isLastFmConfiguredMock.mockReturnValue(true)
+ getSessionKeyForUserMock.mockResolvedValue('session-key')
+ getTrackMetadataMock.mockResolvedValue(testMetadata)
+ lastFmUpdateNowPlayingMock.mockResolvedValue(undefined)
+
+ await updateLastFmNowPlaying(mockQueue, mockTrack)
+
+ expect(lastFmUpdateNowPlayingMock).toHaveBeenCalledWith(
+ 'Test Artist',
+ 'Test Song',
+ 225,
+ 'session-key',
+ testMetadata
)
})
})
@@ -536,7 +586,8 @@ describe('trackNowPlaying handlers', () => {
'Test Song',
expect.any(Number),
225,
- 'session-key'
+ 'session-key',
+ undefined
)
})
@@ -552,7 +603,8 @@ describe('trackNowPlaying handlers', () => {
'Current Song',
expect.any(Number),
200,
- 'session-key'
+ 'session-key',
+ undefined
)
})
@@ -602,7 +654,8 @@ describe('trackNowPlaying handlers', () => {
expect.anything(),
expect.any(Number),
undefined,
- expect.anything()
+ expect.anything(),
+ undefined
)
})
@@ -614,5 +667,51 @@ describe('trackNowPlaying handlers', () => {
expect(lastFmScrobbleMock).not.toHaveBeenCalled()
})
+
+ it('logs when metadata is not found', async () => {
+ isLastFmConfiguredMock.mockReturnValue(true)
+ getSessionKeyForUserMock.mockResolvedValue('session-key')
+ getTrackMetadataMock.mockResolvedValue(null)
+ lastFmScrobbleMock.mockResolvedValue(undefined)
+ debugLogMock.mockClear()
+
+ await scrobbleCurrentTrackIfLastFm(mockQueue, mockTrack)
+
+ // Should have called debugLog when metadata not found
+ const debugLogCalls = debugLogMock.mock.calls
+ const metadataNotFoundCall = debugLogCalls.find(
+ (call: any[]) =>
+ call[0]?.message?.includes('metadata not found')
+ )
+ expect(metadataNotFoundCall).toBeDefined()
+ })
+
+ it('forwards metadata to lastFmScrobble when available', async () => {
+ // Match the real LastFmTrackMetadata shape so the spec catches
+ // regressions if the type tightens or new required fields are added.
+ const testMetadata = {
+ artist: 'Test Artist',
+ title: 'Test Song',
+ album: 'Test Album',
+ albumArtist: 'Test Artist',
+ mbid: 'test-mbid',
+ duration: 225000,
+ }
+ isLastFmConfiguredMock.mockReturnValue(true)
+ getSessionKeyForUserMock.mockResolvedValue('session-key')
+ getTrackMetadataMock.mockResolvedValue(testMetadata)
+ lastFmScrobbleMock.mockResolvedValue(undefined)
+
+ await scrobbleCurrentTrackIfLastFm(mockQueue, mockTrack)
+
+ expect(lastFmScrobbleMock).toHaveBeenCalledWith(
+ 'Test Artist',
+ 'Test Song',
+ expect.any(Number),
+ 225,
+ 'session-key',
+ testMetadata
+ )
+ })
})
})
diff --git a/packages/bot/src/handlers/player/trackNowPlaying.ts b/packages/bot/src/handlers/player/trackNowPlaying.ts
index eac509e94..382f39dda 100644
--- a/packages/bot/src/handlers/player/trackNowPlaying.ts
+++ b/packages/bot/src/handlers/player/trackNowPlaying.ts
@@ -13,6 +13,7 @@ import type { QueueMetadata } from '../../types/QueueMetadata'
import {
isLastFmConfigured,
getSessionKeyForUser,
+ getTrackMetadata,
updateNowPlaying as lastFmUpdateNowPlaying,
scrobble as lastFmScrobble,
} from '../../lastfm'
@@ -244,12 +245,20 @@ export async function updateLastFmNowPlaying(
if (!sessionKey) return
const durationSec =
track.durationMS > 0 ? Math.round(track.durationMS / 1000) : undefined
+ const meta = await getTrackMetadata(track.author, track.title)
+ if (!meta) {
+ debugLog({
+ message: 'Last.fm metadata not found, updating now-playing without metadata',
+ data: { artist: track.author, title: track.title },
+ })
+ }
try {
await lastFmUpdateNowPlaying(
track.author,
track.title,
durationSec,
sessionKey,
+ meta ?? undefined,
)
trackNowPlayingState.setLastFmTrackStartTime(
queue.guild.id,
@@ -287,6 +296,13 @@ export async function scrobbleCurrentTrackIfLastFm(
trackToScrobble.durationMS > 0
? Math.round(trackToScrobble.durationMS / 1000)
: undefined
+ const meta = await getTrackMetadata(trackToScrobble.author, trackToScrobble.title)
+ if (!meta) {
+ debugLog({
+ message: 'Last.fm metadata not found, scrobbling without metadata',
+ data: { artist: trackToScrobble.author, title: trackToScrobble.title },
+ })
+ }
try {
await lastFmScrobble(
trackToScrobble.author,
@@ -294,6 +310,7 @@ export async function scrobbleCurrentTrackIfLastFm(
timestamp,
durationSec,
sessionKey,
+ meta ?? undefined,
)
} catch (err) {
const is403 = err instanceof Error && err.message.includes('403')
diff --git a/packages/bot/src/handlers/reactionHandler.spec.ts b/packages/bot/src/handlers/reactionHandler.spec.ts
deleted file mode 100644
index db59c3ce2..000000000
--- a/packages/bot/src/handlers/reactionHandler.spec.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-
-const getConfigMock = jest.fn()
-const upsertEntryMock = jest.fn()
-const errorLogMock = jest.fn()
-
-jest.mock('@lucky/shared/services', () => ({
- starboardService: {
- getConfig: (...args: unknown[]) => getConfigMock(...args),
- upsertEntry: (...args: unknown[]) => upsertEntryMock(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-import { handleReactionEvents } from './reactionHandler'
-
-function makeClient() {
- const handlers: Record = {}
- return {
- on: jest.fn((event: string, fn: Function) => {
- handlers[event] = fn
- }),
- channels: { fetch: jest.fn() },
- _handlers: handlers,
- }
-}
-
-function makeReaction(overrides: any = {}) {
- return {
- partial: false,
- fetch: jest.fn(),
- emoji: { name: '⭐' },
- count: 2,
- message: {
- partial: false,
- fetch: jest.fn(),
- guild: { id: 'guild-1' },
- id: 'msg-1',
- channelId: 'ch-1',
- author: {
- id: 'author-1',
- username: 'Author',
- tag: 'Author#0001',
- displayAvatarURL: () => 'https://x',
- },
- content: 'hello',
- channel: { name: 'general' },
- url: 'https://discord.com/msg',
- },
- ...overrides,
- }
-}
-
-function makeUser(id = 'user-1') {
- return { partial: false, fetch: jest.fn(), id, bot: false }
-}
-
-const DEFAULT_CONFIG = {
- channelId: 'starboard-ch',
- emoji: '⭐',
- threshold: 2,
- selfStar: false,
-}
-
-describe('handleReactionEvents', () => {
- let client: ReturnType
-
- beforeEach(() => {
- jest.clearAllMocks()
- client = makeClient()
- handleReactionEvents(client as any)
- })
-
- it('registers MessageReactionAdd event handler', () => {
- expect(client.on).toHaveBeenCalledWith(
- 'messageReactionAdd',
- expect.any(Function),
- )
- })
-
- it('does nothing when reaction is from a bot', async () => {
- getConfigMock.mockResolvedValue(DEFAULT_CONFIG)
- const reaction = makeReaction()
- const user = { ...makeUser(), bot: true }
- await client._handlers['messageReactionAdd'](reaction, user)
- expect(getConfigMock).not.toHaveBeenCalled()
- })
-
- it('does nothing when message has no guild', async () => {
- const reaction = makeReaction({
- message: { ...makeReaction().message, guild: null },
- })
- await client._handlers['messageReactionAdd'](reaction, makeUser())
- expect(getConfigMock).not.toHaveBeenCalled()
- })
-
- it('does nothing when no starboard config exists', async () => {
- getConfigMock.mockResolvedValue(null)
- await client._handlers['messageReactionAdd'](makeReaction(), makeUser())
- expect(upsertEntryMock).not.toHaveBeenCalled()
- })
-
- it('does nothing when emoji does not match config', async () => {
- getConfigMock.mockResolvedValue({ ...DEFAULT_CONFIG, emoji: '💎' })
- await client._handlers['messageReactionAdd'](makeReaction(), makeUser())
- expect(upsertEntryMock).not.toHaveBeenCalled()
- })
-
- it('does nothing when selfStar is false and author reacts to own message', async () => {
- getConfigMock.mockResolvedValue({ ...DEFAULT_CONFIG, selfStar: false })
- upsertEntryMock.mockResolvedValue({ starboardMsgId: null })
- const reaction = makeReaction()
- const user = makeUser('author-1')
- await client._handlers['messageReactionAdd'](reaction, user)
- expect(upsertEntryMock).not.toHaveBeenCalled()
- })
-
- it('upserts entry and does not post when below threshold', async () => {
- getConfigMock.mockResolvedValue({ ...DEFAULT_CONFIG, threshold: 5 })
- upsertEntryMock.mockResolvedValue({ starboardMsgId: null })
- const reaction = makeReaction({ count: 2 })
- await client._handlers['messageReactionAdd'](reaction, makeUser())
- expect(upsertEntryMock).toHaveBeenCalled()
- expect(client.channels.fetch).not.toHaveBeenCalled()
- })
-
- it('posts new starboard message when threshold reached and no prior post', async () => {
- getConfigMock.mockResolvedValue(DEFAULT_CONFIG)
- upsertEntryMock.mockResolvedValue({ starboardMsgId: null })
- const mockMsg = { id: 'posted-msg' }
- const mockChannel = {
- isTextBased: () => true,
- send: jest.fn().mockResolvedValue(mockMsg),
- messages: { fetch: jest.fn() },
- }
- client.channels.fetch = jest.fn().mockResolvedValue(mockChannel)
- await client._handlers['messageReactionAdd'](makeReaction(), makeUser())
- expect(mockChannel.send).toHaveBeenCalledWith(
- expect.objectContaining({ embeds: expect.any(Array) }),
- )
- expect(upsertEntryMock).toHaveBeenCalledTimes(2)
- })
-
- it('edits existing starboard message when threshold reached and post exists', async () => {
- getConfigMock.mockResolvedValue(DEFAULT_CONFIG)
- upsertEntryMock.mockResolvedValue({ starboardMsgId: 'existing-1' })
- const editMock = jest.fn().mockResolvedValue(undefined)
- const mockChannel = {
- isTextBased: () => true,
- send: jest.fn(),
- messages: {
- fetch: jest.fn().mockResolvedValue({ edit: editMock }),
- },
- }
- client.channels.fetch = jest.fn().mockResolvedValue(mockChannel)
- await client._handlers['messageReactionAdd'](makeReaction(), makeUser())
- expect(editMock).toHaveBeenCalledWith(
- expect.objectContaining({ embeds: expect.any(Array) }),
- )
- expect(mockChannel.send).not.toHaveBeenCalled()
- })
-
- it('logs error when handler throws', async () => {
- getConfigMock.mockRejectedValue(new Error('DB fail'))
- await client._handlers['messageReactionAdd'](makeReaction(), makeUser())
- expect(errorLogMock).toHaveBeenCalled()
- })
-
- it('does not send when starboard channel is not text-based', async () => {
- getConfigMock.mockResolvedValue(DEFAULT_CONFIG)
- upsertEntryMock.mockResolvedValue({ starboardMsgId: null })
- const notTextChannel = { isTextBased: () => false, send: jest.fn() }
- client.channels.fetch = jest.fn().mockResolvedValue(notTextChannel)
- await client._handlers['messageReactionAdd'](makeReaction(), makeUser())
- expect(notTextChannel.send).not.toHaveBeenCalled()
- })
-
- it('logs error when upsertEntry throws after posting to starboard', async () => {
- getConfigMock.mockResolvedValue(DEFAULT_CONFIG)
- upsertEntryMock.mockResolvedValue({ starboardMsgId: null })
- const postMock = jest.fn().mockResolvedValue({ id: 'star-msg-1' })
- const mockChannel = {
- isTextBased: () => true,
- send: postMock,
- messages: { fetch: jest.fn() },
- }
- client.channels.fetch = jest.fn().mockResolvedValue(mockChannel)
- upsertEntryMock
- .mockResolvedValueOnce({ starboardMsgId: null })
- .mockRejectedValueOnce(new Error('upsert failed'))
- await client._handlers['messageReactionAdd'](makeReaction(), makeUser())
- expect(postMock).toHaveBeenCalled()
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Error handling reaction:' }),
- )
- })
-
- it('gracefully handles starboard message fetch failure when editing', async () => {
- getConfigMock.mockResolvedValue(DEFAULT_CONFIG)
- upsertEntryMock.mockResolvedValue({ starboardMsgId: 'existing-1' })
- const mockChannel = {
- isTextBased: () => true,
- send: jest.fn(),
- messages: {
- fetch: jest.fn().mockRejectedValue(new Error('message not found')),
- },
- }
- client.channels.fetch = jest.fn().mockResolvedValue(mockChannel)
- await client._handlers['messageReactionAdd'](makeReaction(), makeUser())
- expect(mockChannel.messages.fetch).toHaveBeenCalledWith('existing-1')
- expect(mockChannel.send).not.toHaveBeenCalled()
- expect(errorLogMock).not.toHaveBeenCalled()
- })
-})
diff --git a/packages/bot/src/handlers/webMusic/index.spec.ts b/packages/bot/src/handlers/webMusic/index.spec.ts
deleted file mode 100644
index 7538f2bcc..000000000
--- a/packages/bot/src/handlers/webMusic/index.spec.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { beforeEach, afterEach, describe, expect, it, jest } from '@jest/globals'
-import { setupWebMusicHandler } from './index'
-
-const connectMock = jest.fn()
-const subscribeMock = jest.fn()
-const publishStateMock = jest.fn()
-const buildQueueStateMock = jest.fn()
-const infoLogMock = jest.fn()
-const errorLogMock = jest.fn()
-const debugLogMock = jest.fn()
-
-jest.mock('@lucky/shared/services', () => ({
- musicControlService: {
- connect: (...args: unknown[]) => connectMock(...args),
- subscribeToCommands: (...args: unknown[]) => subscribeMock(...args),
- sendResult: jest.fn(),
- publishState: (...args: unknown[]) => publishStateMock(...args),
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- infoLog: (...args: unknown[]) => infoLogMock(...args),
- errorLog: (...args: unknown[]) => errorLogMock(...args),
- debugLog: (...args: unknown[]) => debugLogMock(...args),
-}))
-
-jest.mock('./mappers', () => ({
- buildQueueState: (...args: unknown[]) => buildQueueStateMock(...args),
-}))
-
-function makeClient(queues: unknown[] = []) {
- const eventsHandlers: Record void> = {}
- return {
- player: {
- events: {
- on: (event: string, handler: (...args: unknown[]) => void) => {
- eventsHandlers[event] = handler
- },
- },
- nodes: {
- cache: {
- values: () => queues[Symbol.iterator](),
- },
- },
- },
- _eventsHandlers: eventsHandlers,
- }
-}
-
-describe('setupWebMusicHandler', () => {
- beforeEach(() => {
- jest.useFakeTimers()
- connectMock.mockResolvedValue(undefined)
- subscribeMock.mockResolvedValue(undefined)
- publishStateMock.mockResolvedValue(undefined)
- })
-
- afterEach(() => {
- jest.useRealTimers()
- })
-
- it('connects and subscribes on setup', async () => {
- const client = makeClient()
- await setupWebMusicHandler(client as unknown as Parameters[0])
- expect(connectMock).toHaveBeenCalledTimes(1)
- expect(subscribeMock).toHaveBeenCalledTimes(1)
- expect(infoLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Web music handler initialized' }),
- )
- })
-
- it('logs error when connect fails', async () => {
- connectMock.mockRejectedValue(new Error('Redis down'))
- const client = makeClient()
- await setupWebMusicHandler(client as unknown as Parameters[0])
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Failed to setup web music handler:' }),
- )
- })
-
- describe('periodic state publish (setInterval)', () => {
- it('publishes state for active queues on interval tick', async () => {
- const activeQueue = { guild: { id: 'guild-1' } }
- const client = makeClient([activeQueue])
- buildQueueStateMock.mockResolvedValue({
- guildId: 'guild-1',
- isPlaying: true,
- tracks: [],
- })
-
- await setupWebMusicHandler(client as unknown as Parameters[0])
-
- await jest.advanceTimersByTimeAsync(30000)
-
- expect(buildQueueStateMock).toHaveBeenCalledWith(
- expect.anything(),
- 'guild-1',
- )
- expect(publishStateMock).toHaveBeenCalledWith(
- expect.objectContaining({ guildId: 'guild-1', isPlaying: true }),
- )
- })
-
- it('publishes state when queue has tracks even if not playing', async () => {
- const queue = { guild: { id: 'guild-2' } }
- const client = makeClient([queue])
- buildQueueStateMock.mockResolvedValue({
- guildId: 'guild-2',
- isPlaying: false,
- tracks: [{ title: 'Song 1' }],
- })
-
- await setupWebMusicHandler(client as unknown as Parameters[0])
-
- await jest.advanceTimersByTimeAsync(30000)
-
- expect(publishStateMock).toHaveBeenCalledWith(
- expect.objectContaining({ guildId: 'guild-2' }),
- )
- })
-
- it('skips publishing for idle queues (not playing, no tracks)', async () => {
- const queue = { guild: { id: 'guild-3' } }
- const client = makeClient([queue])
- buildQueueStateMock.mockResolvedValue({
- guildId: 'guild-3',
- isPlaying: false,
- tracks: [],
- })
-
- await setupWebMusicHandler(client as unknown as Parameters[0])
-
- await jest.advanceTimersByTimeAsync(30000)
-
- expect(buildQueueStateMock).toHaveBeenCalled()
- expect(publishStateMock).not.toHaveBeenCalled()
- })
-
- it('skips null queue entries', async () => {
- const client = makeClient([null])
- await setupWebMusicHandler(client as unknown as Parameters[0])
-
- await jest.advanceTimersByTimeAsync(30000)
-
- expect(buildQueueStateMock).not.toHaveBeenCalled()
- })
-
- it('logs error and continues when buildQueueState throws', async () => {
- const queue = { guild: { id: 'guild-err' } }
- const client = makeClient([queue])
- buildQueueStateMock.mockRejectedValue(new Error('state build failed'))
-
- await setupWebMusicHandler(client as unknown as Parameters[0])
-
- await jest.advanceTimersByTimeAsync(30000)
-
- expect(debugLogMock).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Error during periodic state publish' }),
- )
- expect(publishStateMock).not.toHaveBeenCalled()
- })
-
- it('logs non-Error throws as string', async () => {
- const queue = { guild: { id: 'guild-err2' } }
- const client = makeClient([queue])
- buildQueueStateMock.mockRejectedValue('string error')
-
- await setupWebMusicHandler(client as unknown as Parameters[0])
-
- await jest.advanceTimersByTimeAsync(30000)
-
- expect(debugLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- data: expect.objectContaining({ error: 'string error' }),
- }),
- )
- })
- })
-})
diff --git a/packages/bot/src/index.spec.ts b/packages/bot/src/index.spec.ts
deleted file mode 100644
index 1fce6061e..000000000
--- a/packages/bot/src/index.spec.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-import {
- afterEach,
- beforeEach,
- describe,
- expect,
- it,
- jest,
-} from '@jest/globals'
-
-const ensureEnvironmentMock = jest.fn<() => Promise>()
-const setupErrorHandlersMock = jest.fn()
-const initializeSentryMock = jest.fn()
-const flushSentryMock = jest.fn<(timeout?: number) => Promise>()
-const debugLogMock = jest.fn()
-const errorLogMock = jest.fn()
-const initializeBotMock = jest.fn<() => Promise>()
-const dependencyCheckStartMock = jest.fn()
-
-jest.mock('@lucky/shared/config', () => ({
- ensureEnvironment: (...args: unknown[]) => ensureEnvironmentMock(...args),
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- setupErrorHandlers: (...args: unknown[]) => setupErrorHandlersMock(...args),
- initializeSentry: (...args: unknown[]) => initializeSentryMock(...args),
- flushSentry: (...args: unknown[]) => flushSentryMock(...args),
- debugLog: (...args: unknown[]) => debugLogMock(...args),
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-jest.mock('./bot/start', () => ({
- initializeBot: (...args: unknown[]) => initializeBotMock(...args),
-}))
-
-jest.mock('./services/DependencyCheckService', () => ({
- dependencyCheckService: {
- start: (...args: unknown[]) => dependencyCheckStartMock(...args),
- },
-}))
-
-describe('bot entrypoint', () => {
- const originalEnv = process.env
- const originalExit = process.exit
-
- beforeEach(() => {
- jest.resetModules()
- jest.clearAllMocks()
- process.env = {
- ...originalEnv,
- NODE_ENV: 'production',
- SENTRY_ENVIRONMENT: 'production',
- SENTRY_RELEASE: 'lucky-bot@test-sha',
- SENTRY_SERVER_NAME: 'bot-host',
- DEPENDENCY_CHECK_ENABLED: 'false',
- }
- flushSentryMock.mockResolvedValue(true)
- ensureEnvironmentMock.mockResolvedValue()
- initializeBotMock.mockResolvedValue()
- process.exit = jest.fn() as unknown as typeof process.exit
- })
-
- afterEach(() => {
- process.env = originalEnv
- process.exit = originalExit
- })
-
- it('starts the bot with sentry metadata and optional dependency checks', async () => {
- process.env.DEPENDENCY_CHECK_ENABLED = 'true'
-
- await import('./index')
-
- expect(ensureEnvironmentMock).toHaveBeenCalled()
- expect(setupErrorHandlersMock).toHaveBeenCalled()
- expect(initializeSentryMock).toHaveBeenCalledWith({
- appName: 'lucky',
- serviceName: 'bot',
- release: 'lucky-bot@test-sha',
- serverName: 'bot-host',
- environment: 'production',
- tags: {
- runtime: 'discord-bot',
- },
- })
- expect(dependencyCheckStartMock).toHaveBeenCalled()
- expect(debugLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Starting bot in environment: production',
- }),
- )
- expect(initializeBotMock).toHaveBeenCalled()
- expect(process.exit).not.toHaveBeenCalled()
- })
-
- it('flushes sentry and exits when startup fails', async () => {
- const startupError = new Error('bot startup failed')
- initializeBotMock.mockRejectedValue(startupError)
-
- await import('./index')
- await new Promise((resolve) => setImmediate(resolve))
-
- expect(errorLogMock).toHaveBeenCalledWith({
- message: 'Failed to start bot:',
- error: startupError,
- })
- expect(flushSentryMock).toHaveBeenCalledWith(3000)
- expect(process.exit).toHaveBeenCalledWith(1)
- })
-
- it('handles SIGTERM signal for graceful shutdown', async () => {
- const shutdownBotMock = jest.fn<() => Promise>()
- jest.mock('./bot/start', () => ({
- initializeBot: (...args: unknown[]) => initializeBotMock(...args),
- shutdown: (...args: unknown[]) => shutdownBotMock(...args),
- }))
-
- let sigTermHandler: (() => void) | null = null
- const originalOn = process.on
- process.on = jest.fn((signal: string, handler: any) => {
- if (signal === 'SIGTERM') {
- sigTermHandler = handler
- }
- return process as any
- })
-
- await import('./index')
-
- expect(sigTermHandler).not.toBeNull()
- if (sigTermHandler) {
- sigTermHandler()
- await new Promise((resolve) => setImmediate(resolve))
- }
-
- expect(debugLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Received SIGTERM'),
- })
- )
-
- process.on = originalOn
- })
-
- it('handles SIGINT signal for graceful shutdown', async () => {
- const shutdownBotMock = jest.fn<() => Promise>()
- jest.mock('./bot/start', () => ({
- initializeBot: (...args: unknown[]) => initializeBotMock(...args),
- shutdown: (...args: unknown[]) => shutdownBotMock(...args),
- }))
-
- let sigIntHandler: (() => void) | null = null
- const originalOn = process.on
- process.on = jest.fn((signal: string, handler: any) => {
- if (signal === 'SIGINT') {
- sigIntHandler = handler
- }
- return process as any
- })
-
- await import('./index')
-
- expect(sigIntHandler).not.toBeNull()
- if (sigIntHandler) {
- sigIntHandler()
- await new Promise((resolve) => setImmediate(resolve))
- }
-
- expect(debugLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Received SIGINT'),
- })
- )
-
- process.on = originalOn
- })
-
- it('prevents concurrent shutdown operations', async () => {
- const shutdownBotMock = jest.fn<() => Promise>()
- shutdownBotMock.mockResolvedValue()
-
- jest.mock('./bot/start', () => ({
- initializeBot: (...args: unknown[]) => initializeBotMock(...args),
- shutdown: (...args: unknown[]) => shutdownBotMock(...args),
- }))
-
- let sigTermHandler: (() => void) | null = null
- const originalOn = process.on
- process.on = jest.fn((signal: string, handler: any) => {
- if (signal === 'SIGTERM') {
- sigTermHandler = handler
- }
- return process as any
- })
-
- await import('./index')
-
- if (sigTermHandler) {
- sigTermHandler()
- sigTermHandler()
- await new Promise((resolve) => setImmediate(resolve))
- }
-
- expect(debugLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('already in progress'),
- })
- )
-
- process.on = originalOn
- })
-
- it('flushes sentry on shutdown', async () => {
- const shutdownBotMock = jest.fn<() => Promise>()
- shutdownBotMock.mockResolvedValue()
-
- jest.mock('./bot/start', () => ({
- initializeBot: (...args: unknown[]) => initializeBotMock(...args),
- shutdown: (...args: unknown[]) => shutdownBotMock(...args),
- }))
-
- let sigTermHandler: (() => void) | null = null
- const originalOn = process.on
- process.on = jest.fn((signal: string, handler: any) => {
- if (signal === 'SIGTERM') {
- sigTermHandler = handler
- }
- return process as any
- })
-
- await import('./index')
-
- if (sigTermHandler) {
- sigTermHandler()
- await new Promise((resolve) => setImmediate(resolve))
- }
-
- expect(flushSentryMock).toHaveBeenCalledWith(3000)
- expect(process.exit).toHaveBeenCalledWith(0)
-
- process.on = originalOn
- })
-
- it('exits with code 0 after successful shutdown', async () => {
- const shutdownBotMock = jest.fn<() => Promise>()
- shutdownBotMock.mockResolvedValue()
-
- jest.mock('./bot/start', () => ({
- initializeBot: (...args: unknown[]) => initializeBotMock(...args),
- shutdown: (...args: unknown[]) => shutdownBotMock(...args),
- }))
-
- let sigTermHandler: (() => void) | null = null
- const originalOn = process.on
- process.on = jest.fn((signal: string, handler: any) => {
- if (signal === 'SIGTERM') {
- sigTermHandler = handler
- }
- return process as any
- })
-
- await import('./index')
-
- if (sigTermHandler) {
- sigTermHandler()
- await new Promise((resolve) => setImmediate(resolve))
- }
-
- expect(process.exit).toHaveBeenCalledWith(0)
-
- process.on = originalOn
- })
-
- it('handles error during shutdown gracefully', async () => {
- const shutdownBotMock = jest.fn<() => Promise>()
- const shutdownError = new Error('shutdown failed')
- shutdownBotMock.mockRejectedValue(shutdownError)
-
- jest.mock('./bot/start', () => ({
- initializeBot: (...args: unknown[]) => initializeBotMock(...args),
- shutdown: (...args: unknown[]) => shutdownBotMock(...args),
- }))
-
- let sigTermHandler: (() => void) | null = null
- const originalOn = process.on
- process.on = jest.fn((signal: string, handler: any) => {
- if (signal === 'SIGTERM') {
- sigTermHandler = handler
- }
- return process as any
- })
-
- await import('./index')
-
- if (sigTermHandler) {
- sigTermHandler()
- await new Promise((resolve) => setImmediate(resolve))
- }
-
- expect(errorLogMock).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('Error during'),
- })
- )
-
- process.on = originalOn
- })
-})
diff --git a/packages/bot/src/lastfm/index.ts b/packages/bot/src/lastfm/index.ts
index 0ab321291..818d05a5d 100644
--- a/packages/bot/src/lastfm/index.ts
+++ b/packages/bot/src/lastfm/index.ts
@@ -2,6 +2,7 @@ export {
isLastFmConfigured,
getSessionKeyForUser,
getTopTracks,
+ getTrackMetadata,
getRecentTracks,
getSimilarTracks,
getArtistTopTags,
@@ -10,10 +11,11 @@ export {
isLastFmInvalidSessionError,
normalizeLastFmArtist,
normalizeLastFmTitle,
+ parseArtists,
updateNowPlaying,
scrobble,
} from './lastFmApi'
-export type { LastFmTopTrack, LastFmPeriod } from './lastFmApi'
+export type { LastFmTopTrack, LastFmPeriod, LastFmTrackMetadata } from './lastFmApi'
export {
consumeLastFmSeedSlice,
consumeBlendedSeedSlice,
diff --git a/packages/bot/src/lastfm/lastFmApi.spec.ts b/packages/bot/src/lastfm/lastFmApi.spec.ts
index 2c062e93f..c7dcbca43 100644
--- a/packages/bot/src/lastfm/lastFmApi.spec.ts
+++ b/packages/bot/src/lastfm/lastFmApi.spec.ts
@@ -10,6 +10,7 @@ import {
getSessionKeyForUser,
isLastFmInvalidSessionError,
updateNowPlaying,
+ scrobble,
normalizeLastFmArtist,
normalizeLastFmTitle,
getTopTracks,
@@ -18,6 +19,10 @@ import {
getTagTopTracks,
getLovedTracks,
getArtistTopTags,
+ parseArtists,
+ getTrackMetadata,
+ __resetMetadataCacheForTests,
+ LastFmSessionExpiredError,
} from './lastFmApi'
const getSessionKeyMock =
@@ -37,6 +42,16 @@ const fetchMock =
) => Promise
>()
+const debugLogMock = jest.fn()
+
+jest.mock('@lucky/shared/utils/general/log', () => ({
+ debugLog: (params: unknown) => debugLogMock(params),
+ warnLog: jest.fn(),
+ errorLog: jest.fn(),
+ infoLog: jest.fn(),
+ successLog: jest.fn(),
+}))
+
jest.mock('@lucky/shared/services', () => ({
lastFmLinkService: {
getSessionKey: (discordId: string) => getSessionKeyMock(discordId),
@@ -102,6 +117,71 @@ describe('lastFmApi', () => {
expect(request.body).toContain('api_sig=')
})
+ it('includes album and albumArtist in updateNowPlaying when metadata provided', async () => {
+ await updateNowPlaying(
+ 'Artist Name',
+ 'Track Name',
+ 187,
+ 'session-123',
+ { album: 'Test Album', albumArtist: 'Album Artist' }
+ )
+
+ const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]
+ const request = lastCall?.[1] as { body: string }
+ expect(request.body).toContain('album=Test+Album')
+ expect(request.body).toContain('albumArtist=Album+Artist')
+ })
+
+ it('includes mbid in updateNowPlaying when metadata provided', async () => {
+ await updateNowPlaying(
+ 'Artist Name',
+ 'Track Name',
+ 187,
+ 'session-123',
+ { mbid: 'test-mbid-123' }
+ )
+
+ const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]
+ const request = lastCall?.[1] as { body: string }
+ expect(request.body).toContain('mbid=test-mbid-123')
+ })
+
+ it('omits metadata fields when not provided to updateNowPlaying', async () => {
+ await updateNowPlaying('Artist Name', 'Track Name', 187, 'session-123')
+
+ const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]
+ const request = lastCall?.[1] as { body: string }
+ expect(request.body).not.toContain('album=')
+ expect(request.body).not.toContain('albumArtist=')
+ expect(request.body).not.toContain('mbid=')
+ })
+
+ describe('blank-input guard', () => {
+ it('returns early without calling API when artist is empty string', async () => {
+ await updateNowPlaying('', 'Track Name', 187, 'session-123')
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('returns early without calling API when track is empty string', async () => {
+ await updateNowPlaying('Artist Name', '', 187, 'session-123')
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('returns early without calling API when artist is whitespace-only', async () => {
+ await updateNowPlaying(' ', 'Track Name', 187, 'session-123')
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('returns early without calling API when track is whitespace-only', async () => {
+ await updateNowPlaying('Artist Name', ' ', 187, 'session-123')
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+ })
+
describe('normalizeLastFmArtist', () => {
it('strips " - Topic" suffix', () => {
expect(normalizeLastFmArtist('Doja Cat - Topic')).toBe('Doja Cat')
@@ -154,6 +234,29 @@ describe('lastFmApi', () => {
})
})
+ describe('LastFmSessionExpiredError', () => {
+ it('creates error with default message', () => {
+ const error = new LastFmSessionExpiredError()
+ expect(error).toBeInstanceOf(Error)
+ expect(error.message).toBe(
+ 'Last.fm session key has expired (error code 9)',
+ )
+ expect(error.name).toBe('LastFmSessionExpiredError')
+ })
+
+ it('creates error with custom message', () => {
+ const customMsg = 'Custom expiry message'
+ const error = new LastFmSessionExpiredError(customMsg)
+ expect(error.message).toBe(customMsg)
+ expect(error.name).toBe('LastFmSessionExpiredError')
+ })
+
+ it('is instanceof Error', () => {
+ const error = new LastFmSessionExpiredError()
+ expect(error instanceof Error).toBe(true)
+ })
+ })
+
describe('getTopTracks', () => {
it('returns mapped tracks on success', async () => {
fetchMock.mockResolvedValueOnce({
@@ -438,6 +541,17 @@ describe('lastFmApi', () => {
expect(isLastFmInvalidSessionError(error)).toBe(true)
})
+
+ it('detects LastFmSessionExpiredError instances regardless of message', () => {
+ expect(
+ isLastFmInvalidSessionError(new LastFmSessionExpiredError()),
+ ).toBe(true)
+ expect(
+ isLastFmInvalidSessionError(
+ new LastFmSessionExpiredError('totally different message'),
+ ),
+ ).toBe(true)
+ })
})
describe('getTagTopTracks', () => {
@@ -746,4 +860,419 @@ describe('lastFmApi', () => {
expect(result).toEqual([{ artist: 'Oasis', title: 'Wonderwall' }])
})
})
+
+ describe('parseArtists', () => {
+ it('handles empty string without throwing', () => {
+ const result = parseArtists('')
+ expect(result).toEqual({
+ primary: '',
+ featured: [],
+ })
+ })
+
+ it('returns single artist as primary with no featured', () => {
+ expect(parseArtists('Radiohead')).toEqual({
+ primary: 'Radiohead',
+ featured: [],
+ })
+ })
+
+ it('strips " - Topic" suffix before splitting', () => {
+ expect(parseArtists('Radiohead - Topic')).toEqual({
+ primary: 'Radiohead',
+ featured: [],
+ })
+ })
+
+ it('splits on "feat." separator', () => {
+ expect(parseArtists('Drake feat. Rihanna')).toEqual({
+ primary: 'Drake',
+ featured: ['Rihanna'],
+ })
+ })
+
+ it('splits on "ft." separator', () => {
+ expect(parseArtists('Post Malone ft. Swae Lee')).toEqual({
+ primary: 'Post Malone',
+ featured: ['Swae Lee'],
+ })
+ })
+
+ it('splits on "&" separator', () => {
+ expect(parseArtists('Jay-Z & Kanye West')).toEqual({
+ primary: 'Jay-Z',
+ featured: ['Kanye West'],
+ })
+ })
+
+ it('splits on "×" separator', () => {
+ expect(parseArtists('James Blake × Frank Ocean')).toEqual({
+ primary: 'James Blake',
+ featured: ['Frank Ocean'],
+ })
+ })
+
+ it('correctly handles unicode artist string with multiplication sign', () => {
+ expect(parseArtists('BTS × Halsey')).toEqual({
+ primary: 'BTS',
+ featured: ['Halsey'],
+ })
+ })
+
+ it('splits on word-boundary "x" separator', () => {
+ expect(parseArtists('Travis Scott x Drake')).toEqual({
+ primary: 'Travis Scott',
+ featured: ['Drake'],
+ })
+ })
+
+ it('does not split on "x" inside a word', () => {
+ const result = parseArtists('Rex Orange County')
+ expect(result.primary).toBe('Rex Orange County')
+ expect(result.featured).toEqual([])
+ })
+
+ it('splits on "vs." separator', () => {
+ expect(parseArtists('Biggie vs. Tupac')).toEqual({
+ primary: 'Biggie',
+ featured: ['Tupac'],
+ })
+ })
+
+ it('splits on "with" separator', () => {
+ expect(parseArtists('Eric Clapton with B.B. King')).toEqual({
+ primary: 'Eric Clapton',
+ featured: ['B.B. King'],
+ })
+ })
+
+ it('handles multiple featured artists in a chain', () => {
+ const result = parseArtists('Kendrick Lamar feat. Drake & J. Cole')
+ expect(result.primary).toBe('Kendrick Lamar')
+ expect(result.featured.length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('is case-insensitive on separator', () => {
+ expect(parseArtists('Artist FEAT. Featured').primary).toBe('Artist')
+ })
+ })
+
+ describe('getTrackMetadata', () => {
+ beforeEach(() => {
+ process.env.LASTFM_API_KEY = 'test-key'
+ process.env.LASTFM_API_SECRET = 'test-secret'
+ // Module-level caches persist across tests; reset so each case
+ // starts clean and ordering can't silently leak fixtures.
+ __resetMetadataCacheForTests()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it.each([
+ ['blank artist', '', 'Track'],
+ ['blank title', 'Artist', ''],
+ ['whitespace artist', ' ', 'Track'],
+ ['whitespace title', 'Artist', ' '],
+ ])('returns null without fetching on %s', async (_label, artist, title) => {
+ const result = await getTrackMetadata(artist, title)
+ expect(result).toBeNull()
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('returns canonical metadata on successful fetch', async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ track: {
+ name: 'Bohemian Rhapsody',
+ artist: { name: 'Queen' },
+ album: { title: 'A Night at the Opera', artist: 'Queen' },
+ mbid: 'abc-123',
+ duration: '354000',
+ },
+ }),
+ })
+
+ const result = await getTrackMetadata('Queen', 'Bohemian Rhapsody')
+
+ expect(result).toEqual({
+ artist: 'Queen',
+ title: 'Bohemian Rhapsody',
+ album: 'A Night at the Opera',
+ albumArtist: 'Queen',
+ mbid: 'abc-123',
+ duration: 354000,
+ })
+ })
+
+ it('re-fetches when cached entry has expired TTL', async () => {
+ // Use fake timers to control Date.now()
+ jest.useFakeTimers()
+ const now = 1000000
+
+ // Set up the first fetch
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ track: {
+ name: 'First Version',
+ artist: { name: 'Artist A' },
+ },
+ }),
+ })
+
+ // First call caches the result at time = 1000000
+ jest.setSystemTime(now)
+ const first = await getTrackMetadata('Artist A', 'First Version')
+ expect(first!.title).toBe('First Version')
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+
+ // Fast-forward past TTL (24 hours = 86400000 ms)
+ jest.setSystemTime(now + 86400001)
+
+ // Set up second fetch
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ track: {
+ name: 'Second Version',
+ artist: { name: 'Artist A' },
+ },
+ }),
+ })
+
+ // Second call should re-fetch because cache expired
+ const second = await getTrackMetadata('Artist A', 'First Version')
+ expect(second!.title).toBe('Second Version')
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('provides empty string/zero for missing optional fields', async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ track: {
+ name: 'Some Track',
+ artist: { name: 'Some Artist' },
+ },
+ }),
+ })
+
+ const result = await getTrackMetadata('Some Artist', 'Some Track')
+
+ expect(result).not.toBeNull()
+ expect(result!.album).toBe('')
+ expect(result!.mbid).toBe('')
+ expect(result!.duration).toBe(0)
+ expect(result!.albumArtist).toBe('')
+ })
+
+ it('returns cached result on second call without fetching again', async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ track: {
+ name: 'Cached Track',
+ artist: { name: 'Cached Artist' },
+ },
+ }),
+ })
+
+ await getTrackMetadata('Cached Artist', 'Cached Track')
+ await getTrackMetadata('cached artist', 'cached track')
+
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns null on non-ok HTTP response', async () => {
+ fetchMock.mockResolvedValueOnce({ ok: false })
+ const result = await getTrackMetadata('Artist', 'Track')
+ expect(result).toBeNull()
+ })
+
+ it('logs HTTP error and returns null when response is not ok', async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ } as MockFetchResponse)
+
+ const result = await getTrackMetadata('Artist', 'Title')
+
+ expect(result).toBeNull()
+ expect(debugLogMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ status: 404 }),
+ }),
+ )
+ })
+
+ it('returns null when API returns error field', async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ error: 6, message: 'Track not found' }),
+ })
+ const result = await getTrackMetadata('Nobody', 'Nonexistent')
+ expect(result).toBeNull()
+ })
+
+ it('returns null when track field missing', async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ })
+ const result = await getTrackMetadata('Artist', 'Track')
+ expect(result).toBeNull()
+ })
+
+ it('returns null on fetch error', async () => {
+ fetchMock.mockRejectedValueOnce(new Error('network'))
+ const result = await getTrackMetadata('Artist', 'Track')
+ expect(result).toBeNull()
+ })
+
+ it('returns null when Last.fm is not configured', async () => {
+ delete process.env.LASTFM_API_KEY
+ const result = await getTrackMetadata('Artist', 'Track')
+ expect(result).toBeNull()
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('deduplicates concurrent requests with the same key, fetching only once', async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ track: {
+ name: 'Dedup Test Track',
+ artist: { name: 'Dedup Test Artist' },
+ album: { title: 'Dedup Album' },
+ },
+ }),
+ })
+
+ // Fire two concurrent requests with identical (artist, title)
+ const [result1, result2] = await Promise.all([
+ getTrackMetadata('Dedup Test Artist', 'Dedup Test Track'),
+ getTrackMetadata('Dedup Test Artist', 'Dedup Test Track'),
+ ])
+
+ // Both should return the same result
+ expect(result1).toEqual({
+ artist: 'Dedup Test Artist',
+ title: 'Dedup Test Track',
+ album: 'Dedup Album',
+ albumArtist: '',
+ mbid: '',
+ duration: 0,
+ })
+ expect(result2).toEqual(result1)
+
+ // Underlying fetch should have been called exactly once
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('looks up Last.fm with the primary artist when the input is a collaboration', async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ track: {
+ name: 'Hotline Bling',
+ artist: { name: 'Drake' },
+ album: { title: 'Views' },
+ },
+ }),
+ })
+
+ await getTrackMetadata('Drake feat. Rihanna', 'Hotline Bling')
+
+ const url = String(fetchMock.mock.calls[0]![0])
+ // Primary artist should be sent encoded; the "feat. Rihanna" suffix
+ // must not appear in the query string.
+ expect(url).toContain('artist=Drake&')
+ expect(url).not.toContain('Rihanna')
+ })
+ })
+
+ describe('scrobble', () => {
+ it('sends signed scrobble payload with timestamp', async () => {
+ const timestamp = Math.floor(Date.now() / 1000)
+ await scrobble('Artist Name', 'Track Name', timestamp, 187, 'session-123')
+
+ const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]
+ const request = lastCall?.[1] as { body: string }
+ expect(request.body).toContain('method=track.scrobble')
+ expect(request.body).toContain('artist=Artist+Name')
+ expect(request.body).toContain('track=Track+Name')
+ expect(request.body).toContain(`timestamp=${timestamp}`)
+ expect(request.body).toContain('duration=187')
+ expect(request.body).toContain('api_sig=')
+ })
+
+ it('includes album and albumArtist in scrobble when metadata provided', async () => {
+ const timestamp = Math.floor(Date.now() / 1000)
+ await scrobble(
+ 'Artist Name',
+ 'Track Name',
+ timestamp,
+ 187,
+ 'session-123',
+ { album: 'Test Album', albumArtist: 'Album Artist' }
+ )
+
+ const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]
+ const request = lastCall?.[1] as { body: string }
+ expect(request.body).toContain('album=Test+Album')
+ expect(request.body).toContain('albumArtist=Album+Artist')
+ })
+
+ it('includes mbid in scrobble when metadata provided', async () => {
+ const timestamp = Math.floor(Date.now() / 1000)
+ await scrobble(
+ 'Artist Name',
+ 'Track Name',
+ timestamp,
+ 187,
+ 'session-123',
+ { mbid: 'test-mbid-456' }
+ )
+
+ const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]
+ const request = lastCall?.[1] as { body: string }
+ expect(request.body).toContain('mbid=test-mbid-456')
+ })
+
+ it('omits metadata fields when not provided to scrobble', async () => {
+ const timestamp = Math.floor(Date.now() / 1000)
+ await scrobble('Artist Name', 'Track Name', timestamp, 187, 'session-123')
+
+ const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]
+ const request = lastCall?.[1] as { body: string }
+ expect(request.body).not.toContain('album=')
+ expect(request.body).not.toContain('albumArtist=')
+ expect(request.body).not.toContain('mbid=')
+ })
+
+ it('returns early without calling API when sessionKey is falsy', async () => {
+ const timestamp = Math.floor(Date.now() / 1000)
+ await scrobble('Artist Name', 'Track Name', timestamp, 187, null)
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('returns early without calling API when artist is blank', async () => {
+ const timestamp = Math.floor(Date.now() / 1000)
+ await scrobble(' ', 'Track Name', timestamp, 187, 'session-123')
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('returns early without calling API when track is blank', async () => {
+ const timestamp = Math.floor(Date.now() / 1000)
+ await scrobble('Artist Name', ' ', timestamp, 187, 'session-123')
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/packages/bot/src/lastfm/lastFmApi.ts b/packages/bot/src/lastfm/lastFmApi.ts
index 05c7efb5d..c117938ab 100644
--- a/packages/bot/src/lastfm/lastFmApi.ts
+++ b/packages/bot/src/lastfm/lastFmApi.ts
@@ -6,10 +6,22 @@
import crypto from 'node:crypto'
import { lastFmLinkService } from '@lucky/shared/services'
-import { logAndWarn } from '@lucky/shared/utils/error'
+import { logAndSwallow, logAndWarn } from '@lucky/shared/utils/error'
+import { debugLog } from '@lucky/shared/utils/general/log'
const API_BASE = 'https://ws.audioscrobbler.com/2.0/'
+/**
+ * Error thrown when Last.fm session key has expired (error code 9).
+ * Caller should re-authenticate the user.
+ */
+export class LastFmSessionExpiredError extends Error {
+ constructor(message = 'Last.fm session key has expired (error code 9)') {
+ super(message)
+ this.name = 'LastFmSessionExpiredError'
+ }
+}
+
function getApiConfig(): { apiKey: string; secret: string } | null {
const apiKey = process.env.LASTFM_API_KEY
const secret = process.env.LASTFM_API_SECRET
@@ -82,6 +94,9 @@ async function signedPost(
message?: string
}
if (data.error) {
+ if (data.error === 9) {
+ throw new LastFmSessionExpiredError()
+ }
throw new Error(
`Last.fm ${method}: ${data.error} - ${data.message ?? ''}`,
)
@@ -102,6 +117,136 @@ export function normalizeLastFmTitle(raw: string): string {
return raw.replace(TITLE_NOISE_PARENS, '').replace(FEAT_CLAUSE, '').trim()
}
+export type LastFmTrackMetadata = {
+ artist: string
+ title: string
+ album: string
+ albumArtist: string
+ mbid: string
+ duration: number
+}
+
+// Whitespace bounds use bounded repetition (S5852) so the matcher remains
+// strictly linear regardless of input. Last.fm artist strings rarely contain
+// runs of internal whitespace, so {0,4}/{1,4} comfortably covers real input.
+const FEAT_ARTIST_SEPARATORS =
+ /\s{0,4}(?:feat\.?|ft\.?|&|×|\bx\b|\bvs\.?|\bwith\b)\s{1,4}/i
+
+export function parseArtists(raw: string): {
+ primary: string
+ featured: string[]
+} {
+ const normalized = normalizeLastFmArtist(raw)
+ const parts = normalized
+ .split(FEAT_ARTIST_SEPARATORS)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ return { primary: parts[0] ?? normalized, featured: parts.slice(1) }
+}
+
+const TRACK_METADATA_CACHE = new Map<
+ string,
+ { meta: LastFmTrackMetadata; expiresAt: number }
+>()
+const TRACK_METADATA_TTL_MS = 24 * 60 * 60 * 1000
+const TRACK_METADATA_CACHE_MAX = 5000
+const TRACK_METADATA_IN_FLIGHT = new Map<
+ string,
+ Promise
+>()
+
+export async function getTrackMetadata(
+ artist: string,
+ title: string,
+): Promise {
+ const config = getApiConfig()
+ if (!config) return null
+ // Bail before allocating cache / in-flight slots so blank inputs don't
+ // pollute the maps or burn a Last.fm request.
+ const trimmedArtist = artist?.trim()
+ const trimmedTitle = title?.trim()
+ if (!trimmedArtist || !trimmedTitle) return null
+ const key = `${trimmedArtist.toLowerCase()}::${trimmedTitle.toLowerCase()}`
+ const cached = TRACK_METADATA_CACHE.get(key)
+ const now = Date.now()
+ if (cached && cached.expiresAt > now) return cached.meta
+
+ // Deduplicate concurrent fetches
+ const inFlight = TRACK_METADATA_IN_FLIGHT.get(key)
+ if (inFlight) return inFlight
+
+ // Last.fm's track.getInfo does not handle collaboration strings —
+ // "Drake feat. Rihanna" returns error 6 (Track not found). Use the
+ // primary artist so multi-artist inputs still resolve metadata + art.
+ const lookupArtist = parseArtists(trimmedArtist).primary
+ const promise = (async () => {
+ try {
+ const response = await fetch(
+ `${API_BASE}?method=track.getInfo&artist=${encodeURIComponent(lookupArtist)}&track=${encodeURIComponent(trimmedTitle)}&autocorrect=1&format=json&api_key=${config.apiKey}`, // NOSONAR
+ )
+ if (!response.ok) {
+ debugLog({ message: 'lastFmApi: getTrackMetadata HTTP error', data: { status: response.status, statusText: response.statusText } })
+ return null
+ }
+ const data = (await response.json()) as {
+ error?: number
+ track?: {
+ name: string
+ artist: { name: string }
+ album?: { title: string; artist?: string }
+ mbid?: string
+ duration?: string
+ }
+ }
+ if (data.error || !data.track) return null
+ const t = data.track
+ const album = t.album?.title || ''
+ const albumArtist = t.album?.artist || ''
+ const mbid = t.mbid || ''
+ const durationNum = t.duration
+ ? parseInt(t.duration, 10) || 0
+ : 0
+ const meta: LastFmTrackMetadata = {
+ artist: t.artist.name,
+ title: t.name,
+ album,
+ albumArtist,
+ mbid,
+ duration: durationNum,
+ }
+ if (TRACK_METADATA_CACHE.size >= TRACK_METADATA_CACHE_MAX) {
+ const oldest = TRACK_METADATA_CACHE.keys().next().value
+ if (oldest) TRACK_METADATA_CACHE.delete(oldest)
+ }
+ TRACK_METADATA_CACHE.set(key, { meta, expiresAt: now + TRACK_METADATA_TTL_MS })
+ return meta
+ } catch (err) {
+ logAndSwallow(err, 'lastfm.getTrackMetadata', {
+ artist: trimmedArtist,
+ title: trimmedTitle,
+ })
+ return null
+ }
+ })()
+
+ TRACK_METADATA_IN_FLIGHT.set(key, promise)
+ try {
+ return await promise
+ } finally {
+ TRACK_METADATA_IN_FLIGHT.delete(key)
+ }
+}
+
+/**
+ * Test-only: reset the module-level metadata caches so each test starts with
+ * a clean slate. The maps are intentionally module-private at runtime; this
+ * helper exists purely to keep `lastFmApi.spec.ts` test cases isolated.
+ */
+export function __resetMetadataCacheForTests(): void {
+ TRACK_METADATA_CACHE.clear()
+ TRACK_METADATA_IN_FLIGHT.clear()
+}
+
export type LastFmTopTrack = {
artist: string
title: string
@@ -152,15 +297,23 @@ export async function updateNowPlaying(
track: string,
durationSec?: number,
sessionKey?: string | null,
+ metadata?: LastFmTrackMetadata,
): Promise {
if (!sessionKey || !getApiConfig()) return
+ if (!artist?.trim() || !track?.trim()) return
const params: Record = {
- artist: normalizeLastFmArtist(artist),
- track: normalizeLastFmTitle(track),
+ // Prefer Last.fm's canonical (autocorrected) artist/title when a
+ // resolved metadata payload is supplied; only fall back to local
+ // parsing when the caller had no metadata to thread through.
+ artist: metadata?.artist?.trim() || parseArtists(artist).primary,
+ track: metadata?.title?.trim() || normalizeLastFmTitle(track),
}
if (durationSec != null && durationSec > 0) {
params.duration = String(Math.round(durationSec))
}
+ if (metadata?.album) params.album = metadata.album
+ if (metadata?.albumArtist) params.albumArtist = metadata.albumArtist
+ if (metadata?.mbid) params.mbid = metadata.mbid
await signedPost('track.updateNowPlaying', params, sessionKey)
}
@@ -170,20 +323,29 @@ export async function scrobble(
timestamp: number,
durationSec?: number,
sessionKey?: string | null,
+ metadata?: LastFmTrackMetadata,
): Promise {
if (!sessionKey || !getApiConfig()) return
+ if (!artist?.trim() || !track?.trim()) return
const params: Record = {
- artist: normalizeLastFmArtist(artist),
- track: normalizeLastFmTitle(track),
+ // Prefer Last.fm's canonical (autocorrected) artist/title when a
+ // resolved metadata payload is supplied; only fall back to local
+ // parsing when the caller had no metadata to thread through.
+ artist: metadata?.artist?.trim() || parseArtists(artist).primary,
+ track: metadata?.title?.trim() || normalizeLastFmTitle(track),
timestamp: String(Math.floor(timestamp)),
}
if (durationSec != null && durationSec > 0) {
params.duration = String(Math.round(durationSec))
}
+ if (metadata?.album) params.album = metadata.album
+ if (metadata?.albumArtist) params.albumArtist = metadata.albumArtist
+ if (metadata?.mbid) params.mbid = metadata.mbid
await signedPost('track.scrobble', params, sessionKey)
}
export function isLastFmInvalidSessionError(error: unknown): boolean {
+ if (error instanceof LastFmSessionExpiredError) return true
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
@@ -237,6 +399,7 @@ const ARTIST_TAG_CACHE = new Map<
>()
const ARTIST_TAG_TTL_MS = 24 * 60 * 60 * 1000
const ARTIST_TAG_CACHE_MAX = 5000
+const ARTIST_TAG_IN_FLIGHT = new Map>()
export async function getArtistTopTags(
artist: string,
@@ -254,37 +417,52 @@ export async function getArtistTopTags(
return cached.tags
}
- try {
- const response = await fetch(
- `${API_BASE}?method=artist.gettoptags&artist=${encodeURIComponent(trimmed)}&autocorrect=1&format=json&api_key=${config.apiKey}`,
- )
- if (!response.ok) return []
- const data = (await response.json()) as {
- error?: number
- message?: string
- toptags?: {
- tag?: Array<{ name: string; count?: number }>
+ // Deduplicate concurrent fetches
+ const inFlight = ARTIST_TAG_IN_FLIGHT.get(cacheKey)
+ if (inFlight) return inFlight
+
+ const promise = (async () => {
+ try {
+ const response = await fetch(
+ `${API_BASE}?method=artist.gettoptags&artist=${encodeURIComponent(trimmed)}&autocorrect=1&format=json&api_key=${config.apiKey}`, // NOSONAR
+ )
+ if (!response.ok) return []
+ const data = (await response.json()) as {
+ error?: number
+ message?: string
+ toptags?: {
+ tag?: Array<{ name: string; count?: number }>
+ }
}
+ if (typeof data.error === 'number') return []
+ const tags = (data.toptags?.tag ?? [])
+ .slice(0, limit)
+ .map((t) => t.name?.toLowerCase().trim())
+ .filter((name): name is string => !!name)
+
+ if (ARTIST_TAG_CACHE.size >= ARTIST_TAG_CACHE_MAX) {
+ const oldest = ARTIST_TAG_CACHE.keys().next().value
+ if (oldest) ARTIST_TAG_CACHE.delete(oldest)
+ }
+ ARTIST_TAG_CACHE.set(cacheKey, {
+ tags,
+ expiresAt: now + ARTIST_TAG_TTL_MS,
+ })
+
+ return tags
+ } catch (err) {
+ // Surface tag-fetch failures so autoplay tag-based recommendations
+ // remain debuggable when Last.fm is rate-limiting or DNS-flaky.
+ logAndWarn(err, 'lastfm.getArtistTopTags', { artist: trimmed })
+ return []
}
- if (typeof data.error === 'number') return []
- const tags = (data.toptags?.tag ?? [])
- .slice(0, limit)
- .map((t) => t.name?.toLowerCase().trim())
- .filter((name): name is string => !!name)
-
- if (ARTIST_TAG_CACHE.size >= ARTIST_TAG_CACHE_MAX) {
- const oldest = ARTIST_TAG_CACHE.keys().next().value
- if (oldest) ARTIST_TAG_CACHE.delete(oldest)
- }
- ARTIST_TAG_CACHE.set(cacheKey, {
- tags,
- expiresAt: now + ARTIST_TAG_TTL_MS,
- })
+ })()
- return tags
- } catch (err) {
- logAndWarn(err, 'lastfm.getArtistTopTags', { artist: trimmed })
- return []
+ ARTIST_TAG_IN_FLIGHT.set(cacheKey, promise)
+ try {
+ return await promise
+ } finally {
+ ARTIST_TAG_IN_FLIGHT.delete(cacheKey)
}
}
diff --git a/packages/bot/src/register.spec.ts b/packages/bot/src/register.spec.ts
deleted file mode 100644
index 0846eb1d8..000000000
--- a/packages/bot/src/register.spec.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { describe, it, expect, beforeEach, jest } from '@jest/globals'
-
-const downloadCommandsMock = jest.fn()
-const generalCommandsMock = jest.fn()
-const musicCommandsMock = jest.fn()
-const automodCommandsMock = jest.fn()
-const managementCommandsMock = jest.fn()
-const moderationCommandsMock = jest.fn()
-
-jest.mock('./functions/download/commands/index', () => ({
- __esModule: true,
- default: (...args: unknown[]) => downloadCommandsMock(...args),
-}))
-
-jest.mock('./functions/general/commands/index', () => ({
- __esModule: true,
- default: (...args: unknown[]) => generalCommandsMock(...args),
-}))
-
-jest.mock('./functions/music/commands/index', () => ({
- __esModule: true,
- default: (...args: unknown[]) => musicCommandsMock(...args),
-}))
-
-jest.mock('./functions/automod/commands/index', () => ({
- __esModule: true,
- default: (...args: unknown[]) => automodCommandsMock(...args),
-}))
-
-jest.mock('./functions/management/commands/index', () => ({
- __esModule: true,
- default: (...args: unknown[]) => managementCommandsMock(...args),
-}))
-
-jest.mock('./functions/moderation/commands/index', () => ({
- __esModule: true,
- default: (...args: unknown[]) => moderationCommandsMock(...args),
-}))
-
-jest.mock('./handlers/commandsHandler', () => ({
- groupCommands: ({ commands }: { commands: unknown[] }) => commands,
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: jest.fn(),
- debugLog: jest.fn(),
-}))
-
-describe('register.getCommands', () => {
- beforeEach(() => {
- jest.resetModules()
- jest.clearAllMocks()
- downloadCommandsMock.mockResolvedValue([{ data: { name: 'download' } }])
- generalCommandsMock.mockResolvedValue([{ data: { name: 'help' } }])
- musicCommandsMock.mockResolvedValue([{ data: { name: 'play' } }])
- automodCommandsMock.mockResolvedValue([{ data: { name: 'automod' } }])
- managementCommandsMock.mockResolvedValue([
- { data: { name: 'guildconfig' } },
- ])
- moderationCommandsMock.mockResolvedValue([{ data: { name: 'warn' } }])
- })
-
- it('loads all command groups including management and moderation', async () => {
- const { getCommands } = await import('./register')
- const commands = await getCommands()
-
- expect(commands.map((command: any) => command.data.name)).toEqual(
- expect.arrayContaining([
- 'download',
- 'help',
- 'play',
- 'automod',
- 'guildconfig',
- 'warn',
- ]),
- )
- })
-})
diff --git a/packages/bot/src/scripts/sentryTestCli.spec.ts b/packages/bot/src/scripts/sentryTestCli.spec.ts
deleted file mode 100644
index 10437bd7d..000000000
--- a/packages/bot/src/scripts/sentryTestCli.spec.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-const runSentryTestMock = jest.fn()
-const handleSentryTestFailureMock = jest.fn()
-
-jest.mock('./sentryTest', () => ({
- runSentryTest: (...args: unknown[]) => runSentryTestMock(...args),
- handleSentryTestFailure: (...args: unknown[]) =>
- handleSentryTestFailureMock(...args),
-}))
-
-describe('sentryTestCli', () => {
- beforeEach(() => {
- jest.resetModules()
- jest.clearAllMocks()
- })
-
- it('runs the sentry test script on import', async () => {
- runSentryTestMock.mockResolvedValue(undefined)
-
- await import('./sentryTestCli')
- await Promise.resolve()
-
- expect(runSentryTestMock).toHaveBeenCalledTimes(1)
- expect(handleSentryTestFailureMock).not.toHaveBeenCalled()
- })
-
- it('forwards failures to the shared CLI error handler', async () => {
- const error = new Error('boom')
- runSentryTestMock.mockRejectedValue(error)
-
- await import('./sentryTestCli')
- await Promise.resolve()
- await Promise.resolve()
-
- expect(handleSentryTestFailureMock).toHaveBeenCalledWith(error)
- })
-})
diff --git a/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts b/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts
index 5c3a01006..f0f8e7ce7 100644
--- a/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts
+++ b/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts
@@ -30,40 +30,7 @@ describe('RecommendationFeedbackService', () => {
jest.clearAllMocks()
})
- it('uses user-scoped redis key (no guildId in key)', async () => {
- const service = new RecommendationFeedbackService(30)
- getMock.mockResolvedValue(null)
- setexMock.mockResolvedValue(true)
-
- const key = service.buildTrackKey('Song A', 'Artist A')
- await service.setFeedback('guild-1', 'user-1', key, 'dislike')
-
- expect(setexMock).toHaveBeenCalledWith(
- 'music:feedback:user-1',
- expect.any(Number),
- expect.any(String),
- )
- expect(setexMock).not.toHaveBeenCalledWith(
- expect.stringContaining('guild-1'),
- expect.anything(),
- expect.anything(),
- )
- })
- it('uses 30-day TTL in seconds', async () => {
- const service = new RecommendationFeedbackService(30)
- getMock.mockResolvedValue(null)
- setexMock.mockResolvedValue(true)
-
- const key = service.buildTrackKey('Song A', 'Artist A')
- await service.setFeedback('guild-1', 'user-1', key, 'dislike')
-
- expect(setexMock).toHaveBeenCalledWith(
- expect.any(String),
- 30 * 24 * 60 * 60,
- expect.any(String),
- )
- })
it('stores dislike feedback and returns disliked keys', async () => {
const now = 10_000
@@ -204,43 +171,7 @@ describe('RecommendationFeedbackService', () => {
expect(getMock).not.toHaveBeenCalled()
})
- it('setArtistFeedback stores prefer feedback', async () => {
- const service = new RecommendationFeedbackService(30)
- getMock.mockResolvedValue(null)
- setexMock.mockResolvedValue(true)
-
- await service.setArtistFeedback(
- 'guild-1',
- 'user-1',
- 'Taylor Swift',
- 'prefer',
- )
- expect(setexMock).toHaveBeenCalledWith(
- 'music:artist_feedback:user-1',
- 30 * 24 * 60 * 60,
- expect.any(String),
- )
- expect(getMock).toHaveBeenCalledWith('music:artist_feedback:user-1')
- })
-
- it('setArtistFeedback stores block feedback', async () => {
- const service = new RecommendationFeedbackService(30)
- getMock.mockResolvedValue(null)
- setexMock.mockResolvedValue(true)
-
- await service.setArtistFeedback(
- 'guild-1',
- 'user-1',
- 'Unknown Artist',
- 'block',
- )
-
- expect(setexMock).toHaveBeenCalled()
- const callArgs = setexMock.mock.calls[0]
- const storedData = JSON.parse(callArgs[2] as string)
- expect(Object.values(storedData)[0]).toBe('block')
- })
it('getPreferredArtistKeys returns preferred artists', async () => {
const service = new RecommendationFeedbackService(30)
diff --git a/packages/bot/src/spotify/spotifyApi.spec.ts b/packages/bot/src/spotify/spotifyApi.spec.ts
index 396ef14cd..a81fdb93b 100644
--- a/packages/bot/src/spotify/spotifyApi.spec.ts
+++ b/packages/bot/src/spotify/spotifyApi.spec.ts
@@ -14,6 +14,7 @@ import {
getSpotifyRecommendations,
getArtistGenres,
getUserTopArtistsAndTracks,
+ getUserSavedTracks,
} from './spotifyApi'
type MockFetchResponse = {
@@ -836,4 +837,181 @@ describe('spotifyApi', () => {
expect(result).toBeNull()
})
})
+
+ describe('getUserSavedTracks', () => {
+ it('returns empty array when response is not ok', async () => {
+ fetchMock.mockResolvedValue({ ok: false, json: async () => ({}) })
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toEqual([])
+ })
+
+ it('returns track ids from a single page', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ items: [
+ { track: { id: 'track-1' } },
+ { track: { id: 'track-2' } },
+ ],
+ total: 2,
+ }),
+ })
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toEqual(['track-1', 'track-2'])
+ })
+
+ it('paginates until all tracks are fetched', async () => {
+ let callCount = 0
+ fetchMock.mockImplementation(async () => {
+ callCount++
+ if (callCount === 1) {
+ return {
+ ok: true,
+ json: async () => ({
+ items: Array.from({ length: 50 }, (_, i) => ({ track: { id: `track-${i}` } })),
+ total: 60,
+ }),
+ }
+ }
+ return {
+ ok: true,
+ json: async () => ({
+ items: Array.from({ length: 10 }, (_, i) => ({ track: { id: `track-${50 + i}` } })),
+ total: 60,
+ }),
+ }
+ })
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toHaveLength(60)
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('stops when maxTracks (200) is reached', async () => {
+ fetchMock.mockImplementation(async () => ({
+ ok: true,
+ json: async () => ({
+ items: Array.from({ length: 50 }, (_, i) => ({ track: { id: `t${i}` } })),
+ total: 1000,
+ }),
+ }))
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result.length).toBeLessThanOrEqual(200)
+ expect(fetchMock).toHaveBeenCalledTimes(4)
+ })
+
+ it('stops when items array is empty', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ items: [], total: 100 }),
+ })
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toEqual([])
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('breaks on non-ok response mid-pagination', async () => {
+ let callCount = 0
+ fetchMock.mockImplementation(async () => {
+ callCount++
+ if (callCount === 1) {
+ return {
+ ok: true,
+ json: async () => ({
+ items: [{ track: { id: 'track-1' } }],
+ total: 100,
+ }),
+ }
+ }
+ return { ok: false, json: async () => ({}) }
+ })
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toEqual(['track-1'])
+ })
+
+ it('breaks on JSON parse error', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => { throw new Error('JSON error') },
+ })
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toEqual([])
+ })
+
+ it('returns empty array on network error', async () => {
+ fetchMock.mockRejectedValue(new Error('network error'))
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toEqual([])
+ })
+
+ it('skips items without a track id', async () => {
+ fetchMock
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ items: [
+ { track: { id: 'valid-1' } },
+ { track: {} },
+ { track: null },
+ {},
+ { track: { id: 'valid-2' } },
+ ],
+ total: 5,
+ }),
+ })
+ .mockResolvedValue({
+ ok: true,
+ json: async () => ({ items: [], total: 5 }),
+ })
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toEqual(['valid-1', 'valid-2'])
+ })
+
+ it('breaks when data has no items field', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ total: 10 }),
+ })
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toEqual([])
+ })
+
+ it('stops early when data.total matches accumulated count', async () => {
+ let callCount = 0
+ fetchMock.mockImplementation(async () => {
+ callCount++
+ return {
+ ok: true,
+ json: async () => ({
+ items: [{ track: { id: `t${callCount}` } }],
+ total: 1,
+ }),
+ }
+ })
+
+ const result = await getUserSavedTracks('token')
+
+ expect(result).toHaveLength(1)
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+ })
})
diff --git a/packages/bot/src/spotify/spotifyApi.ts b/packages/bot/src/spotify/spotifyApi.ts
index 48b0e8341..47f3cb135 100644
--- a/packages/bot/src/spotify/spotifyApi.ts
+++ b/packages/bot/src/spotify/spotifyApi.ts
@@ -1,5 +1,5 @@
import { logAndSwallow } from '@lucky/shared/utils/error'
-import { debugLog } from '@lucky/shared/utils/general/log'
+import { debugLog, warnLog } from '@lucky/shared/utils/general/log'
export interface SpotifyRecommendationTrack {
id: string
@@ -14,6 +14,96 @@ export type SpotifyAudioFeatureConstraints = {
danceability?: number
}
+async function sleep(ms: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms)
+ })
+}
+
+const DEFAULT_RETRY_AFTER_MS = 1000
+const MAX_RETRY_AFTER_MS = 60 * 1000
+
+/**
+ * Parse a Retry-After header value into a millisecond delay.
+ *
+ * Per RFC 7231 the header may be either:
+ * - delta-seconds: a non-negative integer ("120")
+ * - HTTP-date: an RFC 7231 IMF-fixdate ("Sat, 10 May 2026 12:00:00 GMT")
+ *
+ * Returns null when the header is missing or unparseable so callers can fall
+ * back to a default delay (rather than NaN milliseconds, which becomes a busy
+ * loop).
+ */
+function parseRetryAfterMs(header: string | null): number | null {
+ if (!header) return null
+ const trimmed = header.trim()
+ if (!trimmed) return null
+ // delta-seconds form first — bare integer
+ if (/^\d+$/.test(trimmed)) {
+ const seconds = Number.parseInt(trimmed, 10)
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null
+ }
+ // HTTP-date form
+ const targetMs = Date.parse(trimmed)
+ if (!Number.isFinite(targetMs)) return null
+ const delta = targetMs - Date.now()
+ return delta > 0 ? delta : 0
+}
+
+/**
+ * Throw the Response when its status is retryable so withSpotifyRetry's
+ * catch block can intercept it. fetch() does not throw on HTTP error
+ * statuses, so wrapped callbacks must signal retry-eligible failures
+ * explicitly.
+ */
+function throwIfRetryable(res: Response): void {
+ if (res.status === 429) throw res
+}
+
+async function withSpotifyRetry(fn: () => Promise, maxRetries = 2): Promise {
+ let attempt = 0
+ while (true) {
+ try {
+ return await fn()
+ } catch (error) {
+ const isResponse = error instanceof Response
+ const status = isResponse ? error.status : null
+
+ if (status === 429 && attempt < maxRetries) {
+ const retryAfterHeader = isResponse
+ ? error.headers.get('Retry-After')
+ : null
+ const parsedDelayMs = parseRetryAfterMs(retryAfterHeader)
+ const delayMs = Math.min(
+ parsedDelayMs ?? DEFAULT_RETRY_AFTER_MS,
+ MAX_RETRY_AFTER_MS,
+ )
+ debugLog({
+ message: 'Spotify 429 rate limit, retrying',
+ data: {
+ attempt,
+ maxRetries,
+ delayMs,
+ retryAfterHeader,
+ },
+ })
+ await sleep(delayMs)
+ attempt++
+ continue
+ }
+
+ if (status === 429) {
+ warnLog({
+ message: 'Spotify 429 retry exhausted',
+ data: { attempt, maxRetries },
+ })
+ }
+
+ throw error
+ }
+ }
+}
+
export async function getSpotifyRecommendations(
accessToken: string,
seedTrackIds: string[],
@@ -22,95 +112,99 @@ export async function getSpotifyRecommendations(
): Promise {
if (seedTrackIds.length === 0) return []
try {
- const params = new URLSearchParams({
- seed_tracks: seedTrackIds.slice(0, 5).join(','),
- limit: String(Math.min(limit, 100)),
- })
+ const result = await withSpotifyRetry(async () => {
+ const params = new URLSearchParams({
+ seed_tracks: seedTrackIds.slice(0, 5).join(','),
+ limit: String(Math.min(limit, 100)),
+ })
- if (audioConstraints) {
- const TOLERANCE = 0.25
- if (audioConstraints.energy !== undefined) {
- params.set(
- 'min_energy',
- String(
- Math.max(
- 0,
- audioConstraints.energy - TOLERANCE,
- ).toFixed(2),
- ),
- )
- params.set(
- 'max_energy',
- String(
- Math.min(
- 1,
- audioConstraints.energy + TOLERANCE,
- ).toFixed(2),
- ),
- )
+ if (audioConstraints) {
+ const TOLERANCE = 0.25
+ if (audioConstraints.energy !== undefined) {
+ params.set(
+ 'min_energy',
+ String(
+ Math.max(
+ 0,
+ audioConstraints.energy - TOLERANCE,
+ ).toFixed(2),
+ ),
+ )
+ params.set(
+ 'max_energy',
+ String(
+ Math.min(
+ 1,
+ audioConstraints.energy + TOLERANCE,
+ ).toFixed(2),
+ ),
+ )
+ }
+ if (audioConstraints.valence !== undefined) {
+ params.set(
+ 'min_valence',
+ String(
+ Math.max(
+ 0,
+ audioConstraints.valence - TOLERANCE,
+ ).toFixed(2),
+ ),
+ )
+ params.set(
+ 'max_valence',
+ String(
+ Math.min(
+ 1,
+ audioConstraints.valence + TOLERANCE,
+ ).toFixed(2),
+ ),
+ )
+ }
+ if (audioConstraints.danceability !== undefined) {
+ params.set(
+ 'min_danceability',
+ String(
+ Math.max(
+ 0,
+ audioConstraints.danceability - TOLERANCE,
+ ).toFixed(2),
+ ),
+ )
+ params.set(
+ 'max_danceability',
+ String(
+ Math.min(
+ 1,
+ audioConstraints.danceability + TOLERANCE,
+ ).toFixed(2),
+ ),
+ )
+ }
}
- if (audioConstraints.valence !== undefined) {
- params.set(
- 'min_valence',
- String(
- Math.max(
- 0,
- audioConstraints.valence - TOLERANCE,
- ).toFixed(2),
- ),
- )
- params.set(
- 'max_valence',
- String(
- Math.min(
- 1,
- audioConstraints.valence + TOLERANCE,
- ).toFixed(2),
- ),
- )
+ const res = await fetch(
+ `https://api.spotify.com/v1/recommendations?${params.toString()}`,
+ { headers: { Authorization: `Bearer ${accessToken}` } },
+ )
+ throwIfRetryable(res)
+ if (!res.ok) return []
+ const data = (await res.json().catch(() => null)) as {
+ tracks?: Array<{
+ id?: string
+ name?: string
+ artists?: Array<{ name?: string }>
+ duration_ms?: number
+ }>
}
- if (audioConstraints.danceability !== undefined) {
- params.set(
- 'min_danceability',
- String(
- Math.max(
- 0,
- audioConstraints.danceability - TOLERANCE,
- ).toFixed(2),
- ),
- )
- params.set(
- 'max_danceability',
- String(
- Math.min(
- 1,
- audioConstraints.danceability + TOLERANCE,
- ).toFixed(2),
- ),
- )
- }
- }
- const res = await fetch(
- `https://api.spotify.com/v1/recommendations?${params.toString()}`,
- { headers: { Authorization: `Bearer ${accessToken}` } },
- )
- if (!res.ok) return []
- const data = (await res.json().catch(() => null)) as {
- tracks?: Array<{
- id?: string
- name?: string
- artists?: Array<{ name?: string }>
- duration_ms?: number
- }>
- }
- return (data?.tracks ?? [])
- .filter((t) => t.id && t.name)
- .map((t) => ({
- id: t.id!,
- name: t.name!,
- artists: (t.artists ?? []).map((a) => ({ name: a.name ?? '' })),
- duration_ms: t.duration_ms ?? 0,
- }))
+ return (data?.tracks ?? [])
+ .filter((t) => t.id && t.name)
+ .map((t) => ({
+ id: t.id!,
+ name: t.name!,
+ artists: (t.artists ?? []).map((a) => ({ name: a.name ?? '' })),
+ duration_ms: t.duration_ms ?? 0,
+ }))
+ })
+ return result
} catch (err) {
logAndSwallow(err, 'spotify.getRecommendations', { seedTrackIds: seedTrackIds.length })
return []
@@ -150,27 +244,30 @@ export async function getAudioFeatures(
spotifyTrackId: string,
): Promise {
try {
- const res = await fetch(
- `https://api.spotify.com/v1/audio-features/${spotifyTrackId}`,
- {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
+ const data = await withSpotifyRetry(async () => {
+ const res = await fetch(
+ `https://api.spotify.com/v1/audio-features/${spotifyTrackId}`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
},
- },
- )
+ )
- if (!res.ok) {
- return null
- }
+ throwIfRetryable(res)
+ if (!res.ok) {
+ return null
+ }
- const data = (await res.json().catch(() => null)) as {
- energy?: number
- valence?: number
- danceability?: number
- tempo?: number
- acousticness?: number
- }
+ return (await res.json().catch(() => null)) as {
+ energy?: number
+ valence?: number
+ danceability?: number
+ tempo?: number
+ acousticness?: number
+ }
+ })
if (!data?.energy || typeof data.valence !== 'number') {
return null
@@ -196,34 +293,37 @@ export async function getBatchAudioFeatures(
if (spotifyIds.length === 0) return new Map()
try {
- const ids = spotifyIds.slice(0, 100).join(',')
- const res = await fetch(
- `https://api.spotify.com/v1/audio-features?ids=${ids}`,
- {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
+ const result = await withSpotifyRetry(async () => {
+ const ids = spotifyIds.slice(0, 100).join(',')
+ const res = await fetch(
+ `https://api.spotify.com/v1/audio-features?ids=${ids}`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
},
- },
- )
-
- if (!res.ok) return new Map()
-
- const data = (await res.json().catch(() => null)) as {
- audio_features?: Array<{
- id?: string
- energy?: number
- valence?: number
- danceability?: number
- tempo?: number
- acousticness?: number
- } | null>
- }
+ )
+
+ throwIfRetryable(res)
+ if (!res.ok) return null
+
+ return (await res.json().catch(() => null)) as {
+ audio_features?: Array<{
+ id?: string
+ energy?: number
+ valence?: number
+ danceability?: number
+ tempo?: number
+ acousticness?: number
+ } | null>
+ }
+ })
- const result = new Map()
- if (!data?.audio_features) return result
+ const resultMap = new Map()
+ if (!result?.audio_features) return resultMap
- for (const feature of data.audio_features) {
+ for (const feature of result.audio_features) {
if (
!feature?.id ||
typeof feature.energy !== 'number' ||
@@ -232,7 +332,7 @@ export async function getBatchAudioFeatures(
continue
}
- result.set(feature.id, {
+ resultMap.set(feature.id, {
energy: feature.energy,
valence: feature.valence,
danceability: feature.danceability ?? 0,
@@ -241,7 +341,7 @@ export async function getBatchAudioFeatures(
})
}
- return result
+ return resultMap
} catch (err) {
logAndSwallow(err, 'spotify.getBatchAudioFeatures', { count: spotifyIds.length })
return new Map()
@@ -267,32 +367,35 @@ export async function getArtistPopularity(
})
try {
- const params = new URLSearchParams({
- q: artistName,
- type: 'artist',
- limit: '1',
- })
+ const popularity = await withSpotifyRetry(async () => {
+ const params = new URLSearchParams({
+ q: artistName,
+ type: 'artist',
+ limit: '1',
+ })
- const res = await fetch(
- `https://api.spotify.com/v1/search?${params.toString()}`,
- {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
+ const res = await fetch(
+ `https://api.spotify.com/v1/search?${params.toString()}`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
},
- },
- )
+ )
- if (!res.ok) {
- artistPopularityCache.set(artistName, { value: null })
- return null
- }
+ throwIfRetryable(res)
+ if (!res.ok) {
+ return null
+ }
- const data = (await res.json().catch(() => null)) as {
- artists?: { items?: Array<{ popularity?: number }> }
- }
+ const data = (await res.json().catch(() => null)) as {
+ artists?: { items?: Array<{ popularity?: number }> }
+ }
+
+ return data?.artists?.items?.[0]?.popularity ?? null
+ })
- const popularity = data?.artists?.items?.[0]?.popularity ?? null
artistPopularityCache.set(artistName, { value: popularity })
return popularity
} catch {
@@ -320,32 +423,35 @@ export async function getArtistGenres(
})
try {
- const params = new URLSearchParams({
- q: artistName,
- type: 'artist',
- limit: '1',
- })
+ const genres = await withSpotifyRetry(async () => {
+ const params = new URLSearchParams({
+ q: artistName,
+ type: 'artist',
+ limit: '1',
+ })
- const res = await fetch(
- `https://api.spotify.com/v1/search?${params.toString()}`,
- {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
+ const res = await fetch(
+ `https://api.spotify.com/v1/search?${params.toString()}`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
},
- },
- )
+ )
- if (!res.ok) {
- artistGenresCache.set(artistName, { value: null })
- return []
- }
+ throwIfRetryable(res)
+ if (!res.ok) {
+ return []
+ }
- const data = (await res.json().catch(() => null)) as {
- artists?: { items?: Array<{ genres?: string[] }> }
- }
+ const data = (await res.json().catch(() => null)) as {
+ artists?: { items?: Array<{ genres?: string[] }> }
+ }
+
+ return data?.artists?.items?.[0]?.genres ?? []
+ })
- const genres = data?.artists?.items?.[0]?.genres ?? []
artistGenresCache.set(artistName, { value: genres })
return genres
} catch {
@@ -367,25 +473,28 @@ export async function searchSpotifyTrack(
limit: '1',
})
- const res = await fetch(
- `https://api.spotify.com/v1/search?${params.toString()}`,
- {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
+ return await withSpotifyRetry(async () => {
+ const res = await fetch(
+ `https://api.spotify.com/v1/search?${params.toString()}`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
},
- },
- )
+ )
- if (!res.ok) {
- return null
- }
+ throwIfRetryable(res)
+ if (!res.ok) {
+ return null
+ }
- const data = (await res.json().catch(() => null)) as {
- tracks?: { items?: Array<{ id?: string }> }
- }
+ const data = (await res.json().catch(() => null)) as {
+ tracks?: { items?: Array<{ id?: string }> }
+ }
- return data?.tracks?.items?.[0]?.id ?? null
+ return data?.tracks?.items?.[0]?.id ?? null
+ })
} catch {
return null
}
@@ -407,25 +516,25 @@ export async function getUserTopArtistsAndTracks(
accessToken: string,
): Promise<{ artists: SpotifyTopArtist[]; tracks: SpotifyTopTrack[] } | null> {
try {
- const topArtistsRes = await fetch(
- 'https://api.spotify.com/v1/me/top/artists?limit=20&time_range=medium_term',
- {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
- },
- },
- )
-
- const topTracksRes = await fetch(
- 'https://api.spotify.com/v1/me/top/tracks?limit=20&time_range=medium_term',
- {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
- },
- },
- )
+ const headers = { Authorization: `Bearer ${accessToken}` }
+ const [topArtistsRes, topTracksRes] = await Promise.all([
+ withSpotifyRetry(async () => {
+ const r = await fetch(
+ 'https://api.spotify.com/v1/me/top/artists?limit=20&time_range=medium_term',
+ { method: 'GET', headers },
+ )
+ throwIfRetryable(r)
+ return r
+ }),
+ withSpotifyRetry(async () => {
+ const r = await fetch(
+ 'https://api.spotify.com/v1/me/top/tracks?limit=20&time_range=medium_term',
+ { method: 'GET', headers },
+ )
+ throwIfRetryable(r)
+ return r
+ }),
+ ])
if (!topArtistsRes.ok || !topTracksRes.ok) {
return null
@@ -464,3 +573,76 @@ export async function getUserTopArtistsAndTracks(
return null
}
}
+
+export async function getUserSavedTracks(
+ accessToken: string,
+ limit = 200,
+): Promise {
+ const savedTrackIds: string[] = []
+ const pageLimit = 50
+ const maxTracks = Math.min(limit, 200)
+ let offset = 0
+
+ try {
+ while (offset < maxTracks) {
+ const params = new URLSearchParams({
+ limit: String(pageLimit),
+ offset: String(offset),
+ })
+ const res = await withSpotifyRetry(async () => {
+ const r = await fetch(
+ `https://api.spotify.com/v1/me/tracks?${params.toString()}`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ )
+ throwIfRetryable(r)
+ return r
+ })
+
+ if (!res.ok) {
+ logAndSwallow(
+ new Error(`HTTP ${res.status}`),
+ 'spotify.getUserSavedTracks.request',
+ { status: res.status, offset },
+ )
+ await res.body?.cancel().catch(() => undefined)
+ break
+ }
+
+ type SavedTracksPage = { items: Array<{ track?: { id?: string } }>; total?: number }
+ let data: SavedTracksPage | null = null
+ try {
+ data = (await res.json()) as SavedTracksPage
+ } catch (parseErr) {
+ logAndSwallow(parseErr, 'spotify.getUserSavedTracks.parse', { offset })
+ break
+ }
+
+ if (!data?.items) {
+ break
+ }
+
+ for (const item of data.items) {
+ if (item.track?.id) {
+ savedTrackIds.push(item.track.id)
+ }
+ }
+
+ const allFetched = data.total !== undefined && savedTrackIds.length >= data.total
+ if (savedTrackIds.length >= maxTracks || !data.items.length || allFetched) {
+ break
+ }
+
+ offset += pageLimit
+ }
+
+ return savedTrackIds.slice(0, maxTracks)
+ } catch (err) {
+ logAndSwallow(err, 'spotify.getUserSavedTracks')
+ return []
+ }
+}
diff --git a/packages/bot/src/spotify/spotifyApiRetry.spec.ts b/packages/bot/src/spotify/spotifyApiRetry.spec.ts
new file mode 100644
index 000000000..e1b0bed29
--- /dev/null
+++ b/packages/bot/src/spotify/spotifyApiRetry.spec.ts
@@ -0,0 +1,374 @@
+import * as spotifyApi from './spotifyApi'
+import { debugLog, warnLog } from '@lucky/shared/utils/general/log'
+import { logAndSwallow } from '@lucky/shared/utils/error'
+
+jest.mock('@lucky/shared/utils/general/log')
+jest.mock('@lucky/shared/utils/error')
+
+describe('Spotify API 429 Retry Logic', () => {
+ let fetchMock: jest.Mock
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ fetchMock = jest.fn()
+ global.fetch = fetchMock
+ ;(logAndSwallow as jest.Mock).mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ describe('getAudioFeatures with 429 retry', () => {
+ it('should retry on 429 and succeed on second attempt', async () => {
+ let attemptCount = 0
+
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ if (attemptCount === 1) {
+ const error = new Response(null, { status: 429 })
+ throw error
+ }
+ // Second attempt succeeds
+ return new Response(
+ JSON.stringify({
+ id: 'track1',
+ energy: 0.8,
+ valence: 0.7,
+ danceability: 0.6,
+ tempo: 120,
+ acousticness: 0.2,
+ }),
+ { status: 200 },
+ )
+ })
+
+ const result = await spotifyApi.getAudioFeatures('test-token', 'track1')
+
+ expect(attemptCount).toBe(2)
+ expect(result).not.toBeNull()
+ expect(result?.energy).toBe(0.8)
+ expect(result?.valence).toBe(0.7)
+ expect(debugLog).toHaveBeenCalled()
+ })
+
+ it('should swallow error and return null after exhausting retries on consecutive 429 responses', async () => {
+ let attemptCount = 0
+
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ const error = new Response(null, { status: 429 })
+ throw error
+ })
+
+ const result = await spotifyApi.getAudioFeatures('test-token', 'track1')
+
+ // maxRetries default is 2, so we expect: initial attempt + 2 retries = 3 total
+ expect(attemptCount).toBe(3)
+ expect(warnLog).toHaveBeenCalled()
+ expect(logAndSwallow).toHaveBeenCalled()
+ expect(result).toBeNull()
+ })
+
+ it('should throw immediately on non-429 errors without retrying', async () => {
+ fetchMock.mockImplementation(async () => {
+ throw new Response(null, { status: 401 })
+ })
+
+ const result = await spotifyApi.getAudioFeatures('test-token', 'track1')
+
+ // No retry — only 1 attempt
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ expect(result).toBeNull()
+ expect(logAndSwallow).toHaveBeenCalled()
+ })
+
+ it('should use Retry-After header when present', async () => {
+ let attemptCount = 0
+
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ if (attemptCount === 1) {
+ throw new Response(null, {
+ status: 429,
+ headers: { 'Retry-After': '0' },
+ })
+ }
+ return new Response(
+ JSON.stringify({
+ id: 'track1',
+ energy: 0.8,
+ valence: 0.7,
+ danceability: 0.6,
+ tempo: 120,
+ acousticness: 0.2,
+ }),
+ { status: 200 },
+ )
+ })
+
+ const result = await spotifyApi.getAudioFeatures('test-token', 'track1')
+
+ expect(attemptCount).toBe(2)
+ expect(result).not.toBeNull()
+ expect(debugLog).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ retryAfterHeader: '0' }),
+ }),
+ )
+ })
+ })
+
+ describe('getSpotifyRecommendations with 429 retry', () => {
+ it('should retry on 429 and return recommendations on second attempt', async () => {
+ let attemptCount = 0
+
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ if (attemptCount === 1) {
+ const error = new Response(null, { status: 429 })
+ throw error
+ }
+ return new Response(
+ JSON.stringify({
+ tracks: [
+ {
+ id: 'rec1',
+ name: 'Recommended Song',
+ artists: [{ name: 'Artist' }],
+ duration_ms: 180000,
+ },
+ ],
+ }),
+ { status: 200 },
+ )
+ })
+
+ const result = await spotifyApi.getSpotifyRecommendations('test-token', [
+ 'seed1',
+ ])
+
+ expect(attemptCount).toBe(2)
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('Recommended Song')
+ expect(debugLog).toHaveBeenCalled()
+ })
+
+ it('should swallow error and return empty array after exhausting retries on multiple 429 responses', async () => {
+ let attemptCount = 0
+
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ const error = new Response(null, { status: 429 })
+ throw error
+ })
+
+ const result = await spotifyApi.getSpotifyRecommendations('test-token', ['seed1'])
+
+ expect(attemptCount).toBe(3) // initial + 2 retries
+ expect(warnLog).toHaveBeenCalled()
+ expect(logAndSwallow).toHaveBeenCalled()
+ expect(result).toEqual([])
+ })
+ })
+
+ describe('getBatchAudioFeatures with 429 retry', () => {
+ it('should retry on 429 and return features on second attempt', async () => {
+ let attemptCount = 0
+
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ if (attemptCount === 1) {
+ throw new Response(null, { status: 429 })
+ }
+ return new Response(
+ JSON.stringify({
+ audio_features: [
+ {
+ id: 'batch1',
+ energy: 0.7,
+ valence: 0.6,
+ danceability: 0.5,
+ tempo: 130,
+ acousticness: 0.1,
+ },
+ ],
+ }),
+ { status: 200 },
+ )
+ })
+
+ const result = await spotifyApi.getBatchAudioFeatures('test-token', ['batch1'])
+
+ expect(attemptCount).toBe(2)
+ expect(result.size).toBe(1)
+ expect(result.get('batch1')?.energy).toBe(0.7)
+ })
+
+ it('should return empty map after exhausting retries', async () => {
+ fetchMock.mockImplementation(async () => {
+ throw new Response(null, { status: 429 })
+ })
+
+ const result = await spotifyApi.getBatchAudioFeatures('test-token', ['batch1'])
+
+ expect(result.size).toBe(0)
+ expect(logAndSwallow).toHaveBeenCalled()
+ })
+ })
+
+ describe('getArtistPopularity with 429 retry', () => {
+ it('should retry on 429 and return popularity on second attempt', async () => {
+ let attemptCount = 0
+
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ if (attemptCount === 1) {
+ throw new Response(null, { status: 429 })
+ }
+ return new Response(
+ JSON.stringify({
+ artists: { items: [{ popularity: 82 }] },
+ }),
+ { status: 200 },
+ )
+ })
+
+ const result = await spotifyApi.getArtistPopularity('test-token', 'Retry Artist Popularity')
+
+ expect(attemptCount).toBe(2)
+ expect(result).toBe(82)
+ })
+
+ it('should return null after network error', async () => {
+ fetchMock.mockRejectedValue(new Error('network failure'))
+
+ const result = await spotifyApi.getArtistPopularity('test-token', 'Failing Artist Popularity')
+
+ expect(result).toBeNull()
+ })
+ })
+
+ describe('getArtistGenres with 429 retry', () => {
+ it('should retry on 429 and return genres on second attempt', async () => {
+ let attemptCount = 0
+
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ if (attemptCount === 1) {
+ throw new Response(null, { status: 429 })
+ }
+ return new Response(
+ JSON.stringify({
+ artists: { items: [{ genres: ['pop', 'rock'] }] },
+ }),
+ { status: 200 },
+ )
+ })
+
+ const result = await spotifyApi.getArtistGenres('test-token', 'Retry Artist Genres')
+
+ expect(attemptCount).toBe(2)
+ expect(result).toEqual(['pop', 'rock'])
+ })
+
+ it('should return empty array after network error', async () => {
+ fetchMock.mockRejectedValue(new Error('network failure'))
+
+ const result = await spotifyApi.getArtistGenres('test-token', 'Failing Artist Genres')
+
+ expect(result).toEqual([])
+ })
+ })
+
+ // Regression: real fetch() RESOLVES with a 429 Response (it does not throw
+ // on HTTP error statuses). Verify retry fires when fetch resolves rather
+ // than rejects — Greptile feedback on PR #808.
+ describe('429 retry when fetch resolves (not throws) the Response', () => {
+ it('retries on resolved 429 then succeeds', async () => {
+ let attemptCount = 0
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ if (attemptCount === 1) {
+ return new Response(null, { status: 429 })
+ }
+ return new Response(
+ JSON.stringify({
+ id: 't1',
+ energy: 0.5,
+ valence: 0.5,
+ danceability: 0.5,
+ tempo: 100,
+ acousticness: 0.5,
+ }),
+ { status: 200 },
+ )
+ })
+
+ const result = await spotifyApi.getAudioFeatures('tok', 't1')
+
+ expect(attemptCount).toBe(2)
+ expect(result).not.toBeNull()
+ })
+
+ it('honours Retry-After delta-seconds header from resolved Response', async () => {
+ jest.useFakeTimers()
+ try {
+ let attemptCount = 0
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ if (attemptCount === 1) {
+ return new Response(null, {
+ status: 429,
+ headers: { 'Retry-After': '2' },
+ })
+ }
+ return new Response(
+ JSON.stringify({ id: 't', energy: 0.5, valence: 0.5 }),
+ { status: 200 },
+ )
+ })
+
+ const promise = spotifyApi.getAudioFeatures('tok', 't')
+ await jest.advanceTimersByTimeAsync(2000)
+ await promise
+
+ expect(attemptCount).toBe(2)
+ } finally {
+ jest.useRealTimers()
+ }
+ })
+
+ it('parses Retry-After HTTP-date header without producing NaN delay', async () => {
+ jest.useFakeTimers()
+ try {
+ const baseTime = new Date('2026-05-10T12:00:00Z').getTime()
+ jest.setSystemTime(baseTime)
+ const futureDate = new Date(baseTime + 3000).toUTCString()
+
+ let attemptCount = 0
+ fetchMock.mockImplementation(async () => {
+ attemptCount++
+ if (attemptCount === 1) {
+ return new Response(null, {
+ status: 429,
+ headers: { 'Retry-After': futureDate },
+ })
+ }
+ return new Response(
+ JSON.stringify({ id: 't', energy: 0.5, valence: 0.5 }),
+ { status: 200 },
+ )
+ })
+
+ const promise = spotifyApi.getAudioFeatures('tok', 't')
+ await jest.advanceTimersByTimeAsync(3000)
+ await promise
+
+ expect(attemptCount).toBe(2)
+ } finally {
+ jest.useRealTimers()
+ }
+ })
+ })
+})
diff --git a/packages/bot/src/spotify/spotifyUserSeeds.spec.ts b/packages/bot/src/spotify/spotifyUserSeeds.spec.ts
index 94d647d13..c7338cfa3 100644
--- a/packages/bot/src/spotify/spotifyUserSeeds.spec.ts
+++ b/packages/bot/src/spotify/spotifyUserSeeds.spec.ts
@@ -12,12 +12,15 @@ jest.mock('@lucky/shared/services', () => ({
jest.mock('./spotifyApi', () => ({
getUserTopArtistsAndTracks: jest.fn(),
+ getUserSavedTracks: jest.fn().mockResolvedValue([]),
}))
describe('spotifyUserSeeds', () => {
beforeEach(() => {
jest.clearAllMocks()
+ ;(spotifyApi.getUserSavedTracks as jest.Mock).mockResolvedValue([])
clearUserSeedsCache('test-user-id')
+ ;(spotifyApi.getUserSavedTracks as jest.Mock).mockResolvedValue([])
})
it('should fetch and cache user Spotify seeds', async () => {
@@ -56,6 +59,56 @@ describe('spotifyUserSeeds', () => {
expect(result?.artistIds).toEqual(['artist-1', 'artist-2'])
expect(result?.artistNames).toEqual(new Set(['artist one', 'artist two']))
expect(result?.trackIds).toEqual(['track-1', 'track-2'])
+ expect(result?.likedTrackIds).toEqual([])
+ })
+
+ it('should populate likedTrackIds when getUserSavedTracks returns data', async () => {
+ const mockLink = {
+ spotifyId: 'spotify-123',
+ accessToken: 'token',
+ refreshToken: 'refresh',
+ tokenExpiresAt: new Date(Date.now() + 3600000),
+ spotifyUsername: 'testuser',
+ }
+ const mockSeeds = {
+ artists: [{ id: 'artist-1', name: 'Artist One', genres: ['rock'] }],
+ tracks: [],
+ }
+ ;(spotifyLinkService.getByDiscordId as jest.Mock).mockResolvedValue(mockLink)
+ ;(spotifyLinkService.getValidAccessToken as jest.Mock).mockResolvedValue('token')
+ ;(spotifyApi.getUserTopArtistsAndTracks as jest.Mock).mockResolvedValue(mockSeeds)
+ ;(spotifyApi.getUserSavedTracks as jest.Mock).mockResolvedValue(['liked-1', 'liked-2'])
+
+ clearUserSeedsCache('test-user-id')
+ const result = await getUserSpotifySeeds('test-user-id')
+
+ expect(result?.likedTrackIds).toEqual(['liked-1', 'liked-2'])
+ })
+
+ it('should degrade gracefully when getUserSavedTracks rejects', async () => {
+ const mockLink = {
+ spotifyId: 'spotify-123',
+ accessToken: 'token',
+ refreshToken: 'refresh',
+ tokenExpiresAt: new Date(Date.now() + 3600000),
+ spotifyUsername: 'testuser',
+ }
+ const mockSeeds = {
+ artists: [{ id: 'artist-1', name: 'Artist One', genres: ['rock'] }],
+ tracks: [{ id: 'track-1', name: 'Song One', artist: 'Artist One' }],
+ }
+ ;(spotifyLinkService.getByDiscordId as jest.Mock).mockResolvedValue(mockLink)
+ ;(spotifyLinkService.getValidAccessToken as jest.Mock).mockResolvedValue('token')
+ ;(spotifyApi.getUserTopArtistsAndTracks as jest.Mock).mockResolvedValue(mockSeeds)
+ ;(spotifyApi.getUserSavedTracks as jest.Mock).mockRejectedValue(new Error('network failure'))
+
+ clearUserSeedsCache('test-user-id')
+ const result = await getUserSpotifySeeds('test-user-id')
+
+ expect(result).not.toBeNull()
+ expect(result?.artistIds).toEqual(['artist-1'])
+ expect(result?.trackIds).toEqual(['track-1'])
+ expect(result?.likedTrackIds).toEqual([])
})
it('should return null if user has no Spotify link', async () => {
@@ -113,7 +166,7 @@ describe('spotifyUserSeeds', () => {
expect(result).toBeNull()
})
- it('should cache results for 5 minutes', async () => {
+ it('should cache results for 30 minutes', async () => {
const mockLink = {
spotifyId: 'spotify-123',
accessToken: 'token',
diff --git a/packages/bot/src/spotify/spotifyUserSeeds.ts b/packages/bot/src/spotify/spotifyUserSeeds.ts
index 3baa892c4..8ad65e9e7 100644
--- a/packages/bot/src/spotify/spotifyUserSeeds.ts
+++ b/packages/bot/src/spotify/spotifyUserSeeds.ts
@@ -1,12 +1,13 @@
import { LRUCache } from 'lru-cache'
import { spotifyLinkService } from '@lucky/shared/services'
-import { getUserTopArtistsAndTracks } from './spotifyApi'
+import { getUserTopArtistsAndTracks, getUserSavedTracks } from './spotifyApi'
import { debugLog, errorLog } from '@lucky/shared/utils/general/log'
export interface UserSpotifySeeds {
artistIds: string[]
artistNames: Set
trackIds: string[]
+ likedTrackIds: string[]
}
interface SeededUserEntry {
@@ -14,13 +15,13 @@ interface SeededUserEntry {
fetched: number
}
+const CACHE_TTL_MS = 30 * 60 * 1000
+
const userSeedsCache = new LRUCache({
max: 500,
- ttl: 5 * 60 * 1000,
+ ttl: CACHE_TTL_MS,
})
-const CACHE_TTL_MS = 5 * 60 * 1000
-
export async function getUserSpotifySeeds(userId: string): Promise {
const now = Date.now()
const cached = userSeedsCache.get(userId)
@@ -57,10 +58,13 @@ export async function getUserSpotifySeeds(userId: string): Promise [] as string[])
+
const seeds: UserSpotifySeeds = {
artistIds: seedData.artists.map((a) => a.id),
artistNames: new Set(seedData.artists.map((a) => a.name.toLowerCase())),
trackIds: seedData.tracks.map((t) => t.id),
+ likedTrackIds,
}
userSeedsCache.set(userId, {
@@ -74,6 +78,7 @@ export async function getUserSpotifySeeds(userId: string): Promise ({
- featureToggleService: {
- isEnabled: isEnabledMock,
- },
-}))
-
-jest.mock('@lucky/shared/utils', () => ({
- infoLog: infoLogMock,
-}))
-
-jest.mock('./token', () => ({
- isTwitchConfigured: isTwitchConfiguredMock,
-}))
-
-jest.mock('./eventsubClient', () => ({
- twitchEventSubClient: {
- start: twitchEventSubClientStartMock,
- stop: twitchEventSubClientStopMock,
- refreshSubscriptions: twitchEventSubClientRefreshMock,
- },
-}))
-
-import {
- startTwitchService,
- stopTwitchService,
- refreshTwitchSubscriptions,
-} from './index'
-
-describe('twitch/index', () => {
- let mockDiscordClient: Partial
-
- beforeEach(() => {
- jest.clearAllMocks()
- mockDiscordClient = {}
- })
-
- afterEach(() => {
- jest.clearAllMocks()
- })
-
- describe('startTwitchService', () => {
- it('should return early if feature is disabled', async () => {
- isEnabledMock.mockResolvedValue(false)
-
- await startTwitchService(mockDiscordClient as Client)
-
- expect(twitchEventSubClientStartMock).not.toHaveBeenCalled()
- })
-
- it('should return early if twitch is not configured', async () => {
- isEnabledMock.mockResolvedValue(true)
- isTwitchConfiguredMock.mockReturnValue(false)
-
- await startTwitchService(mockDiscordClient as Client)
-
- expect(twitchEventSubClientStartMock).not.toHaveBeenCalled()
- })
-
- it('should start eventsub client when enabled and configured', async () => {
- isEnabledMock.mockResolvedValue(true)
- isTwitchConfiguredMock.mockReturnValue(true)
- twitchEventSubClientStartMock.mockResolvedValue(undefined)
-
- await startTwitchService(mockDiscordClient as Client)
-
- expect(twitchEventSubClientStartMock).toHaveBeenCalledWith(
- mockDiscordClient,
- )
- expect(infoLogMock).toHaveBeenCalledWith({
- message: 'Twitch EventSub service started',
- })
- })
-
- it('should handle start errors gracefully', async () => {
- isEnabledMock.mockResolvedValue(true)
- isTwitchConfiguredMock.mockReturnValue(true)
- twitchEventSubClientStartMock.mockRejectedValue(
- new Error('Start failed'),
- )
-
- await startTwitchService(mockDiscordClient as Client)
-
- expect(infoLogMock).toHaveBeenCalledWith({
- message: 'Twitch EventSub service failed to start (non-fatal)',
- data: expect.any(Error),
- })
- })
-
- it('should pass discord client to eventsub client', async () => {
- isEnabledMock.mockResolvedValue(true)
- isTwitchConfiguredMock.mockReturnValue(true)
- twitchEventSubClientStartMock.mockResolvedValue(undefined)
-
- const customClient = { id: 'test-client' } as any
-
- await startTwitchService(customClient as Client)
-
- expect(twitchEventSubClientStartMock).toHaveBeenCalledWith(
- customClient,
- )
- })
-
- it('should check feature toggle without parameters', async () => {
- isEnabledMock.mockResolvedValue(false)
- isTwitchConfiguredMock.mockReturnValue(true)
-
- await startTwitchService(mockDiscordClient as Client)
-
- expect(isEnabledMock).toHaveBeenCalledWith('TWITCH_NOTIFICATIONS')
- })
- })
-
- describe('stopTwitchService', () => {
- it('should stop eventsub client', () => {
- stopTwitchService()
-
- expect(twitchEventSubClientStopMock).toHaveBeenCalled()
- })
-
- it('should call stop without arguments', () => {
- stopTwitchService()
-
- expect(twitchEventSubClientStopMock).toHaveBeenCalledWith()
- })
- })
-
- describe('refreshTwitchSubscriptions', () => {
- it('should refresh subscriptions', async () => {
- twitchEventSubClientRefreshMock.mockResolvedValue(undefined)
-
- await refreshTwitchSubscriptions()
-
- expect(twitchEventSubClientRefreshMock).toHaveBeenCalled()
- })
-
- it('should wait for refresh to complete', async () => {
- let resolved = false
- twitchEventSubClientRefreshMock.mockImplementation(
- () =>
- new Promise((resolve) =>
- setTimeout(() => {
- resolved = true
- resolve()
- }, 10),
- ),
- )
-
- await refreshTwitchSubscriptions()
-
- expect(resolved).toBe(true)
- })
-
- it('should handle refresh errors', async () => {
- twitchEventSubClientRefreshMock.mockRejectedValue(
- new Error('Refresh failed'),
- )
-
- await expect(refreshTwitchSubscriptions()).rejects.toThrow(
- 'Refresh failed',
- )
- })
- })
-})
diff --git a/packages/bot/src/utils/general/deferredInteractionReply.spec.ts b/packages/bot/src/utils/general/deferredInteractionReply.spec.ts
deleted file mode 100644
index ce8186b21..000000000
--- a/packages/bot/src/utils/general/deferredInteractionReply.spec.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { describe, it, expect, jest, beforeEach } from '@jest/globals'
-
-const errorLogMock = jest.fn()
-
-jest.mock('@lucky/shared/utils', () => ({
- errorLog: (...args: unknown[]) => errorLogMock(...args),
-}))
-
-import { deferredInteractionReply } from './deferredInteractionReply'
-
-function makeInteraction(
- overrides: Partial<{
- deferred: boolean
- replied: boolean
- deferReply: jest.Mock
- followUp: jest.Mock
- editReply: jest.Mock
- }> = {},
-) {
- return {
- deferred: false,
- replied: false,
- deferReply: jest.fn().mockResolvedValue(undefined),
- followUp: jest.fn().mockResolvedValue(undefined),
- editReply: jest.fn().mockResolvedValue(undefined),
- ...overrides,
- }
-}
-
-describe('deferredInteractionReply', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('defers then edits when not deferred and not replied', async () => {
- const interaction = makeInteraction({ deferred: false, replied: false })
- await deferredInteractionReply(interaction as never, {
- content: 'Hello',
- })
- expect(interaction.deferReply).toHaveBeenCalled()
- expect(interaction.editReply).toHaveBeenCalledWith({ content: 'Hello' })
- expect(interaction.followUp).not.toHaveBeenCalled()
- })
-
- it('passes ephemeral flag when deferring', async () => {
- const interaction = makeInteraction({ deferred: false, replied: false })
- await deferredInteractionReply(interaction as never, {
- content: 'Hi',
- ephemeral: true,
- })
- expect(interaction.deferReply).toHaveBeenCalledWith({ flags: 64 })
- })
-
- it('defers without flags for non-ephemeral', async () => {
- const interaction = makeInteraction({ deferred: false, replied: false })
- await deferredInteractionReply(interaction as never, { content: 'Hi' })
- expect(interaction.deferReply).toHaveBeenCalledWith({
- flags: undefined,
- })
- })
-
- it('calls followUp when already replied', async () => {
- const interaction = makeInteraction({ deferred: true, replied: true })
- await deferredInteractionReply(interaction as never, {
- content: 'Follow up',
- })
- expect(interaction.deferReply).not.toHaveBeenCalled()
- expect(interaction.followUp).toHaveBeenCalledWith({
- content: 'Follow up',
- })
- expect(interaction.editReply).not.toHaveBeenCalled()
- })
-
- it('calls editReply when deferred but not replied', async () => {
- const interaction = makeInteraction({ deferred: true, replied: false })
- await deferredInteractionReply(interaction as never, {
- content: 'Edit',
- })
- expect(interaction.deferReply).not.toHaveBeenCalled()
- expect(interaction.editReply).toHaveBeenCalledWith({ content: 'Edit' })
- expect(interaction.followUp).not.toHaveBeenCalled()
- })
-
- it('logs error and does not throw when interaction throws', async () => {
- const interaction = makeInteraction({
- deferred: true,
- replied: false,
- editReply: jest
- .fn()
- .mockRejectedValue(new Error('interaction expired')),
- })
- await expect(
- deferredInteractionReply(interaction as never, { content: 'x' }),
- ).resolves.not.toThrow()
- expect(errorLogMock).toHaveBeenCalled()
- })
-})
diff --git a/packages/bot/src/utils/general/interactionReply.spec.ts b/packages/bot/src/utils/general/interactionReply.spec.ts
index 8ec596ba6..5a0eb1f19 100644
--- a/packages/bot/src/utils/general/interactionReply.spec.ts
+++ b/packages/bot/src/utils/general/interactionReply.spec.ts
@@ -67,27 +67,6 @@ describe('interactionReply', () => {
})
expect(mockErrorLog).not.toHaveBeenCalled()
})
-
- it('does not attempt to reply on non-replyable interaction', async () => {
- const mockInteraction = {
- isChatInputCommand: jest.fn(() => false),
- isButton: jest.fn(() => false),
- isModalSubmit: jest.fn(() => false),
- isStringSelectMenu: jest.fn(() => false),
- isUserSelectMenu: jest.fn(() => false),
- isChannelSelectMenu: jest.fn(() => false),
- isRoleSelectMenu: jest.fn(() => false),
- isMentionableSelectMenu: jest.fn(() => false),
- } as unknown as Interaction
-
- await interactionReply({
- interaction: mockInteraction,
- content: { content: 'test' },
- })
-
- // Should not call any Discord API methods
- expect(mockErrorLog).not.toHaveBeenCalledWith(expect.anything())
- })
})
describe('chat input command interactions', () => {
diff --git a/packages/bot/src/utils/monitoring/metrics.spec.ts b/packages/bot/src/utils/monitoring/metrics.spec.ts
deleted file mode 100644
index 32f0351a1..000000000
--- a/packages/bot/src/utils/monitoring/metrics.spec.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { describe, it, expect, beforeEach, jest } from '@jest/globals'
-import { recordCommandMetric, recordInteractionMetric, recordMusicMetric, recordErrorMetric } from './metrics'
-import type { CustomClient } from '../../types'
-
-describe('Metrics Monitoring', () => {
- let mockClient: CustomClient
- let mockMetrics: any
-
- beforeEach(() => {
- mockMetrics = {
- commandExecutions: {
- inc: jest.fn()
- },
- commandDuration: {
- observe: jest.fn()
- },
- interactions: {
- inc: jest.fn()
- },
- musicActions: {
- inc: jest.fn()
- },
- errors: {
- inc: jest.fn()
- }
- }
-
- mockClient = {
- metrics: mockMetrics
- } as any
- })
-
- describe('recordCommandMetric', () => {
- it('should record command execution metric', () => {
- recordCommandMetric(mockClient, 'play', 150, true)
-
- expect(mockMetrics.commandExecutions.inc).toHaveBeenCalledWith({
- command: 'play',
- success: 'true'
- })
- expect(mockMetrics.commandDuration.observe).toHaveBeenCalledWith(
- { command: 'play' },
- 150
- )
- })
-
- it('should record failed command execution metric', () => {
- recordCommandMetric(mockClient, 'skip', 200, false)
-
- expect(mockMetrics.commandExecutions.inc).toHaveBeenCalledWith({
- command: 'skip',
- success: 'false'
- })
- expect(mockMetrics.commandDuration.observe).toHaveBeenCalledWith(
- { command: 'skip' },
- 200
- )
- })
-
- it('should not record metrics when client has no metrics', () => {
- mockClient.metrics = null
-
- recordCommandMetric(mockClient, 'play', 150, true)
-
- expect(mockMetrics.commandExecutions.inc).not.toHaveBeenCalled()
- expect(mockMetrics.commandDuration.observe).not.toHaveBeenCalled()
- })
- })
-
- describe('recordInteractionMetric', () => {
- it('should record successful interaction metric', () => {
- recordInteractionMetric(mockClient, 'button', true)
-
- expect(mockMetrics.interactions.inc).toHaveBeenCalledWith({
- type: 'button',
- success: 'true'
- })
- })
-
- it('should record failed interaction metric', () => {
- recordInteractionMetric(mockClient, 'select_menu', false)
-
- expect(mockMetrics.interactions.inc).toHaveBeenCalledWith({
- type: 'select_menu',
- success: 'false'
- })
- })
-
- it('should not record metrics when client has no metrics', () => {
- mockClient.metrics = null
-
- recordInteractionMetric(mockClient, 'button', true)
-
- expect(mockMetrics.interactions.inc).not.toHaveBeenCalled()
- })
- })
-
- describe('recordMusicMetric', () => {
- it('should record music action metric', () => {
- recordMusicMetric(mockClient, 'play', 'guild123')
-
- expect(mockMetrics.musicActions.inc).toHaveBeenCalledWith({
- action: 'play',
- guild_id: 'guild123'
- })
- })
-
- it('should record different music actions', () => {
- recordMusicMetric(mockClient, 'pause', 'guild456')
- recordMusicMetric(mockClient, 'skip', 'guild789')
-
- expect(mockMetrics.musicActions.inc).toHaveBeenCalledTimes(2)
- expect(mockMetrics.musicActions.inc).toHaveBeenNthCalledWith(1, {
- action: 'pause',
- guild_id: 'guild456'
- })
- expect(mockMetrics.musicActions.inc).toHaveBeenNthCalledWith(2, {
- action: 'skip',
- guild_id: 'guild789'
- })
- })
-
- it('should not record metrics when client has no metrics', () => {
- mockClient.metrics = null
-
- recordMusicMetric(mockClient, 'play', 'guild123')
-
- expect(mockMetrics.musicActions.inc).not.toHaveBeenCalled()
- })
- })
-
- describe('recordErrorMetric', () => {
- it('should record error metric with different severities', () => {
- recordErrorMetric(mockClient, 'validation_error', 'low')
- recordErrorMetric(mockClient, 'network_error', 'medium')
- recordErrorMetric(mockClient, 'database_error', 'high')
- recordErrorMetric(mockClient, 'system_error', 'critical')
-
- expect(mockMetrics.errors.inc).toHaveBeenCalledTimes(4)
- expect(mockMetrics.errors.inc).toHaveBeenNthCalledWith(1, {
- type: 'validation_error',
- severity: 'low'
- })
- expect(mockMetrics.errors.inc).toHaveBeenNthCalledWith(2, {
- type: 'network_error',
- severity: 'medium'
- })
- expect(mockMetrics.errors.inc).toHaveBeenNthCalledWith(3, {
- type: 'database_error',
- severity: 'high'
- })
- expect(mockMetrics.errors.inc).toHaveBeenNthCalledWith(4, {
- type: 'system_error',
- severity: 'critical'
- })
- })
-
- it('should not record metrics when client has no metrics', () => {
- mockClient.metrics = null
-
- recordErrorMetric(mockClient, 'test_error', 'low')
-
- expect(mockMetrics.errors.inc).not.toHaveBeenCalled()
- })
- })
-})
diff --git a/packages/bot/src/utils/music/autoplay/artistTagCache.spec.ts b/packages/bot/src/utils/music/autoplay/artistTagCache.spec.ts
new file mode 100644
index 000000000..e2b386b0e
--- /dev/null
+++ b/packages/bot/src/utils/music/autoplay/artistTagCache.spec.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it, jest, beforeEach } from '@jest/globals'
+import {
+ createArtistTagFetcher,
+ hasGenreTag,
+ type ArtistTagFetcher,
+} from './artistTagCache'
+
+jest.mock('../../../lastfm', () => ({
+ getArtistTopTags: jest.fn(),
+}))
+
+const { getArtistTopTags } = require('../../../lastfm')
+
+describe('createArtistTagFetcher', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('returns empty array for undefined artist', async () => {
+ const fetcher = createArtistTagFetcher()
+ const result = await fetcher(undefined)
+ expect(result).toEqual([])
+ })
+
+ it('returns empty array for empty string artist', async () => {
+ const fetcher = createArtistTagFetcher()
+ const result = await fetcher('')
+ expect(result).toEqual([])
+ })
+
+ it('returns empty array for whitespace-only artist', async () => {
+ const fetcher = createArtistTagFetcher()
+ const result = await fetcher(' ')
+ expect(result).toEqual([])
+ })
+
+ it('fetches tags from Last.fm for valid artist', async () => {
+ const tags = ['rock', 'alternative', 'indie']
+ getArtistTopTags.mockResolvedValue(tags)
+
+ const fetcher = createArtistTagFetcher()
+ const result = await fetcher('The Beatles')
+
+ expect(getArtistTopTags).toHaveBeenCalledWith('The Beatles')
+ expect(result).toEqual(tags)
+ })
+
+ it('caches results for same artist (case-insensitive)', async () => {
+ const tags = ['pop', 'electronic']
+ getArtistTopTags.mockResolvedValue(tags)
+
+ const fetcher = createArtistTagFetcher()
+
+ const result1 = await fetcher('Daft Punk')
+ const result2 = await fetcher('daft punk')
+ const result3 = await fetcher('DAFT PUNK')
+
+ expect(getArtistTopTags).toHaveBeenCalledTimes(1)
+ expect(result1).toEqual(tags)
+ expect(result2).toEqual(tags)
+ expect(result3).toEqual(tags)
+ })
+
+ it('coalesces concurrent requests for same artist', async () => {
+ const tags = ['country', 'folk']
+ getArtistTopTags.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(() => resolve(tags), 50)
+ }),
+ )
+
+ const fetcher = createArtistTagFetcher()
+
+ const [result1, result2, result3] = await Promise.all([
+ fetcher('Willie Nelson'),
+ fetcher('Willie Nelson'),
+ fetcher('Willie Nelson'),
+ ])
+
+ expect(getArtistTopTags).toHaveBeenCalledTimes(1)
+ expect(result1).toEqual(tags)
+ expect(result2).toEqual(tags)
+ expect(result3).toEqual(tags)
+ })
+
+ it('returns empty array when Last.fm fails', async () => {
+ getArtistTopTags.mockRejectedValue(new Error('API error'))
+
+ const fetcher = createArtistTagFetcher()
+ const result = await fetcher('Unknown Artist')
+
+ expect(result).toEqual([])
+ })
+
+ it('uses spotify fallback when Last.fm returns empty and fallback provided', async () => {
+ getArtistTopTags.mockResolvedValue([])
+ const spotifyFallback = jest
+ .fn<(artist: string) => Promise>()
+ .mockResolvedValue(['pop', 'latin'])
+
+ const fetcher = createArtistTagFetcher(spotifyFallback)
+ const result = await fetcher('Bad Bunny')
+
+ expect(getArtistTopTags).toHaveBeenCalledWith('Bad Bunny')
+ expect(spotifyFallback).toHaveBeenCalledWith('Bad Bunny')
+ expect(result).toEqual(['pop', 'latin'])
+ })
+
+ it('does not use spotify fallback when Last.fm has tags', async () => {
+ getArtistTopTags.mockResolvedValue(['rock', 'metal'])
+ const spotifyFallback = jest
+ .fn<(artist: string) => Promise>()
+ .mockResolvedValue(['pop'])
+
+ const fetcher = createArtistTagFetcher(spotifyFallback)
+ const result = await fetcher('Metallica')
+
+ expect(spotifyFallback).not.toHaveBeenCalled()
+ expect(result).toEqual(['rock', 'metal'])
+ })
+
+ it('handles spotify fallback errors gracefully', async () => {
+ getArtistTopTags.mockResolvedValue([])
+ const spotifyFallback = jest
+ .fn<(artist: string) => Promise>()
+ .mockRejectedValue(new Error('Spotify API error'))
+
+ const fetcher = createArtistTagFetcher(spotifyFallback)
+ const result = await fetcher('Unknown')
+
+ expect(result).toEqual([])
+ })
+
+ it('converts spotify fallback non-array result to empty array', async () => {
+ getArtistTopTags.mockResolvedValue([])
+ const spotifyFallback = jest
+ .fn<(artist: string) => Promise>()
+ .mockResolvedValue(null as unknown as string[])
+
+ const fetcher = createArtistTagFetcher(spotifyFallback)
+ const result = await fetcher('Artist')
+
+ expect(result).toEqual([])
+ })
+
+ it('caches fallback results same as Last.fm results', async () => {
+ getArtistTopTags.mockResolvedValue([])
+ const spotifyFallback = jest
+ .fn<(artist: string) => Promise>()
+ .mockResolvedValue(['genre1', 'genre2'])
+
+ const fetcher = createArtistTagFetcher(spotifyFallback)
+
+ const result1 = await fetcher('Artist')
+ const result2 = await fetcher('Artist')
+
+ expect(spotifyFallback).toHaveBeenCalledTimes(1)
+ expect(result1).toEqual(result2)
+ })
+})
+
+describe('hasGenreTag', () => {
+ it('returns false when tags array is empty', () => {
+ expect(hasGenreTag([], ['rock', 'pop'])).toBe(false)
+ })
+
+ it('returns true when a tag matches a genre variant', () => {
+ expect(hasGenreTag(['sertanejo', 'country'], ['sertanejo'])).toBe(true)
+ })
+
+ it('returns false when no tags match', () => {
+ expect(hasGenreTag(['rock', 'indie'], ['sertanejo', 'forró'])).toBe(false)
+ })
+
+ it('is case-insensitive', () => {
+ expect(hasGenreTag(['Sertanejo'], ['sertanejo'])).toBe(true)
+ expect(hasGenreTag(['sertanejo'], ['SERTANEJO'])).toBe(true)
+ })
+
+ it('trims whitespace from tags and variants', () => {
+ expect(hasGenreTag([' sertanejo '], ['sertanejo'])).toBe(true)
+ })
+
+ it('returns false when variants array is empty', () => {
+ expect(hasGenreTag(['rock'], [])).toBe(false)
+ })
+})
diff --git a/packages/bot/src/utils/music/autoplay/artistTagCache.ts b/packages/bot/src/utils/music/autoplay/artistTagCache.ts
index cad6c5da9..175881067 100644
--- a/packages/bot/src/utils/music/autoplay/artistTagCache.ts
+++ b/packages/bot/src/utils/music/autoplay/artistTagCache.ts
@@ -2,16 +2,28 @@ import { getArtistTopTags } from '../../../lastfm'
export type ArtistTagFetcher = (artist: string | undefined) => Promise
+export function hasGenreTag(tags: string[], genreVariants: string[]): boolean {
+ if (tags.length === 0) return false
+ const normalized = tags.map((t) => t.toLowerCase().trim())
+ return genreVariants.some((g) => normalized.includes(g.toLowerCase().trim()))
+}
+
/**
- * Per-pass de-duplicating Last.fm artist-tag fetcher. The replenisher creates
- * one of these per autoplay pass and threads it through every candidate
- * collector so we only ask Last.fm for each artist once even when the same
- * artist surfaces in Spotify, Last.fm seeds, and genre searches.
+ * Per-pass de-duplicating artist-tag fetcher. The replenisher creates one per
+ * autoplay pass and threads it through every candidate collector, so each
+ * artist is only looked up once even when it surfaces in multiple sources.
+ *
+ * When a `spotifyFallback` is provided and Last.fm returns no tags (i.e. the
+ * user has not linked Last.fm), the fetcher calls the fallback and caches its
+ * result. This lets the cross-locale veto fire even for artists whose
+ * title/author carry no Spanish text markers (e.g. "Marcos Witt", "Alex Zurdo").
*
* Each call returns a Promise so concurrent in-flight requests for the same
* artist coalesce automatically.
*/
-export function createArtistTagFetcher(): ArtistTagFetcher {
+export function createArtistTagFetcher(
+ spotifyFallback?: (artist: string) => Promise,
+): ArtistTagFetcher {
const cache = new Map>()
return async (artist) => {
@@ -20,7 +32,17 @@ export function createArtistTagFetcher(): ArtistTagFetcher {
if (!key) return []
const cached = cache.get(key)
if (cached) return cached
- const pending = getArtistTopTags(artist).catch(() => [])
+ const pending = getArtistTopTags(artist)
+ .catch(() => [])
+ .then(async (tags) => {
+ if (tags.length === 0 && spotifyFallback) {
+ const result = await Promise.resolve(
+ spotifyFallback(artist),
+ ).catch(() => [])
+ return Array.isArray(result) ? result : []
+ }
+ return tags
+ })
cache.set(key, pending)
return pending
}
diff --git a/packages/bot/src/utils/music/autoplay/autoplayAudit.spec.ts b/packages/bot/src/utils/music/autoplay/autoplayAudit.spec.ts
new file mode 100644
index 000000000..6f35ec2c4
--- /dev/null
+++ b/packages/bot/src/utils/music/autoplay/autoplayAudit.spec.ts
@@ -0,0 +1,213 @@
+import { jest } from '@jest/globals'
+import type { Track } from 'discord-player'
+import { AutoplayAuditCollector } from './autoplayAudit'
+import type { ScoredTrack } from './candidateCollector'
+import type { SessionMood } from './sessionMood'
+
+const infoLogMock = jest.fn()
+
+jest.mock('@lucky/shared/utils', () => ({
+ infoLog: (...args: unknown[]) => infoLogMock(...args),
+ debugLog: jest.fn(),
+ errorLog: jest.fn(),
+ warnLog: jest.fn(),
+}))
+
+function createTrack(overrides: Partial