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 @@

CI - Invite Lucky + Invite Lucky Node.js TypeScript Discord.js @@ -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 = {}): Track { + return { + title: 'Test Track', + author: 'Test Artist', + url: 'https://example.com/track', + id: 'track-id', + durationMS: 180000, + ...overrides, + } as Track +} + +function createScoredTrack(overrides: Partial = {}): ScoredTrack { + return { + track: createTrack(), + score: 0.8, + basis: { source: 'spotify-rec', signals: [] }, + ...overrides, + } +} + +describe('AutoplayAuditCollector', () => { + let collector: AutoplayAuditCollector + + beforeEach(() => { + collector = new AutoplayAuditCollector() + infoLogMock.mockClear() + }) + + describe('recordEvaluated', () => { + it('stores title, artist, score, reason, and status', () => { + const track = createTrack({ title: 'Song A', author: 'Artist A' }) + collector.recordEvaluated(track, 0.9, 'liked artist', 'accepted') + + const record = captureEmit(collector) + expect(record.evaluated).toHaveLength(1) + expect(record.evaluated[0]).toEqual({ + title: 'Song A', + artist: 'Artist A', + score: 0.9, + reason: 'liked artist', + status: 'accepted', + }) + }) + + it('stores rejected status correctly', () => { + const track = createTrack({ title: 'Blocked Song', author: 'Blocked Artist' }) + collector.recordEvaluated(track, -Infinity, 'cross-locale reject', 'rejected') + + const record = captureEmit(collector) + expect(record.evaluated[0]).toMatchObject({ + status: 'rejected', + reason: 'cross-locale reject', + }) + }) + + it('accumulates multiple entries in order', () => { + const t1 = createTrack({ title: 'First', author: 'A1' }) + const t2 = createTrack({ title: 'Second', author: 'A2' }) + const t3 = createTrack({ title: 'Third', author: 'A3' }) + + collector.recordEvaluated(t1, 0.9, 'reason 1', 'accepted') + collector.recordEvaluated(t2, 0.5, 'reason 2', 'accepted') + collector.recordEvaluated(t3, -1, 'reason 3', 'rejected') + + const record = captureEmit(collector) + expect(record.evaluated).toHaveLength(3) + expect(record.evaluated[0]!.title).toBe('First') + expect(record.evaluated[1]!.title).toBe('Second') + expect(record.evaluated[2]!.title).toBe('Third') + expect(record.evaluated[2]!.status).toBe('rejected') + }) + }) + + describe('setFinalSelected', () => { + it('maps ScoredTrack array to selected entries', () => { + const scored: ScoredTrack[] = [ + createScoredTrack({ + track: createTrack({ title: 'Pick A', author: 'Artist X' }), + score: 0.95, + basis: { source: 'spotify-rec', signals: ['preferred artist'] }, + }), + createScoredTrack({ + track: createTrack({ title: 'Pick B', author: 'Artist Y' }), + score: 0.85, + basis: { source: 'lastfm-similar', signals: [] }, + }), + ] + + collector.setFinalSelected(scored) + + const record = captureEmit(collector) + expect(record.selected).toHaveLength(2) + expect(record.selected[0]).toEqual({ + title: 'Pick A', + artist: 'Artist X', + score: 0.95, + reason: 'spotify rec • preferred artist', + }) + expect(record.selected[1]).toEqual({ + title: 'Pick B', + artist: 'Artist Y', + score: 0.85, + reason: 'last.fm similar', + }) + }) + + it('produces empty selected when called with empty array', () => { + collector.setFinalSelected([]) + const record = captureEmit(collector) + expect(record.selected).toHaveLength(0) + }) + }) + + describe('emit', () => { + it('calls infoLog with a well-formed AutoplayAuditRecord', () => { + const beforeEmit = Date.now() + collector.emit('guild-123', 'seed-track', null, { spotify: 3 }, 450) + const afterEmit = Date.now() + + expect(infoLogMock).toHaveBeenCalledTimes(1) + const { data } = infoLogMock.mock.calls[0]![0] as { message: string; data: Record } + + expect(data.guildId).toBe('guild-123') + expect(data.seed).toBe('seed-track') + expect(data.durationMs).toBe(450) + expect(data.sourceCounts).toEqual({ spotify: 3 }) + expect(data.timestamp).toBeGreaterThanOrEqual(beforeEmit) + expect(data.timestamp).toBeLessThanOrEqual(afterEmit) + }) + + it('sets cycleId as guildId-timestamp pattern', () => { + collector.emit('g-456', 'seed', null, {}, 100) + + const { data } = infoLogMock.mock.calls[0]![0] as { message: string; data: Record } + expect(typeof data.cycleId).toBe('string') + expect((data.cycleId as string).startsWith('g-456-')).toBe(true) + }) + + it('sets sessionMoodSummary to null when sessionMood is null', () => { + collector.emit('guild-1', 'seed', null, {}, 0) + + const { data } = infoLogMock.mock.calls[0]![0] as { message: string; data: Record } + expect(data.sessionMoodSummary).toBeNull() + }) + + it('uses dominantLocale from sessionMood when provided', () => { + const mood: SessionMood = { + dominantLocale: 'pt-BR', + restless: false, + trackCount: 5, + localeWeights: new Map([['pt-BR', 5]]), + } + + collector.emit('guild-1', 'seed', mood, {}, 0) + + const { data } = infoLogMock.mock.calls[0]![0] as { message: string; data: Record } + expect(data.sessionMoodSummary).toBe('pt-BR') + }) + + it('includes evaluated and selected entries in the record', () => { + const track = createTrack({ title: 'T1', author: 'A1' }) + collector.recordEvaluated(track, 0.7, 'reason', 'accepted') + collector.setFinalSelected([createScoredTrack({ track, score: 0.7 })]) + + collector.emit('guild-1', 'seed', null, {}, 200) + + const { data } = infoLogMock.mock.calls[0]![0] as { message: string; data: Record } + expect((data.evaluated as unknown[]).length).toBe(1) + expect((data.selected as unknown[]).length).toBe(1) + }) + + it('logs with message "Autoplay audit"', () => { + collector.emit('guild-1', 'seed', null, {}, 0) + + const { message } = infoLogMock.mock.calls[0]![0] as { message: string } + expect(message).toBe('Autoplay audit') + }) + }) +}) + +function captureEmit(collector: AutoplayAuditCollector) { + collector.emit('test-guild', 'test-seed', null, {}, 0) + const call = infoLogMock.mock.calls[infoLogMock.mock.calls.length - 1]![0] as { + message: string + data: { + cycleId: string + guildId: string + timestamp: number + seed: string + sessionMoodSummary: string | null + evaluated: Array<{ title: string; artist: string; score: number; reason: string; status: 'accepted' | 'rejected' }> + selected: Array<{ title: string; artist: string; score: number; reason: string }> + sourceCounts: Record + durationMs: number + } + } + return call.data +} diff --git a/packages/bot/src/utils/music/autoplay/autoplayAudit.ts b/packages/bot/src/utils/music/autoplay/autoplayAudit.ts new file mode 100644 index 000000000..b9435fe5b --- /dev/null +++ b/packages/bot/src/utils/music/autoplay/autoplayAudit.ts @@ -0,0 +1,83 @@ +import type { Track } from 'discord-player' +import { infoLog } from '@lucky/shared/utils' +import type { ScoredTrack } from './candidateCollector' +import { serializeBasis } from './recommendationBasis.js' +import type { SessionMood } from './sessionMood' + +interface EvaluatedCandidate { + title: string + artist: string + score: number + reason: string + status: 'accepted' | 'rejected' +} + +interface SelectedTrack { + title: string + artist: string + score: number + reason: string +} + +export interface AutoplayAuditRecord { + cycleId: string + guildId: string + timestamp: number + seed: string + sessionMoodSummary: string | null + evaluated: EvaluatedCandidate[] + selected: SelectedTrack[] + sourceCounts: Record + durationMs: number +} + +export class AutoplayAuditCollector { + private evaluated: EvaluatedCandidate[] = [] + private selected: SelectedTrack[] = [] + + recordEvaluated( + track: Track, + score: number, + reason: string, + status: 'accepted' | 'rejected', + ): void { + this.evaluated.push({ + title: track.title, + artist: track.author, + score, + reason, + status, + }) + } + + setFinalSelected(tracks: ScoredTrack[]): void { + this.selected = tracks.map((t) => ({ + title: t.track.title, + artist: t.track.author, + score: t.score, + reason: serializeBasis(t.basis), + })) + } + + emit( + guildId: string, + seed: string, + sessionMood: SessionMood | null, + sourceCounts: Record, + durationMs: number, + ): void { + const now = Date.now() + const record: AutoplayAuditRecord = { + cycleId: `${guildId}-${now}`, + guildId, + timestamp: now, + seed, + sessionMoodSummary: sessionMood?.dominantLocale ?? null, + evaluated: this.evaluated, + selected: this.selected, + sourceCounts, + durationMs, + } + infoLog({ message: 'Autoplay audit', data: record }) + } +} diff --git a/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts b/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts index a38d82755..28785a6f4 100644 --- a/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts +++ b/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts @@ -43,7 +43,7 @@ jest.mock('./diversitySelector', () => ({ const calculateRecommendationScoreMock = jest.fn(() => ({ score: 0.5, - reason: 'test', + signals: [], })) const normalizeTrackKeyMock = jest.fn( (title?: string, author?: string) => @@ -118,7 +118,7 @@ describe('candidateCollector', () => { it('should add new candidate to pool', () => { const candidates = new Map() const track = createTrack({ title: 'Song 1', author: 'Artist 1' }) - const recommendation = { score: 0.8, reason: 'test' } + const recommendation = { score: 0.8, source: 'spotify-rec' as const, signals: [] } upsertScoredCandidate(candidates, track, recommendation) @@ -126,7 +126,7 @@ describe('candidateCollector', () => { const entry = Array.from(candidates.values())[0] expect(entry.track).toBe(track) expect(entry.score).toBe(0.8) - expect(entry.reason).toBe('test') + expect(entry.basis.source).toBe('spotify-rec') }) it('should update candidate if new score is higher', () => { @@ -140,17 +140,17 @@ describe('candidateCollector', () => { upsertScoredCandidate(candidates, track1, { score: 0.5, - reason: 'test1', + source: 'spotify-rec', signals: [], }) upsertScoredCandidate(candidates, track2, { score: 0.8, - reason: 'test2', + source: 'spotify-rec', signals: [], }) expect(candidates.size).toBe(1) const entry = Array.from(candidates.values())[0] expect(entry.score).toBe(0.8) - expect(entry.reason).toBe('test2') + expect(entry.basis.source).toBe('spotify-rec') }) it('should keep higher score when duplicate key inserted', () => { @@ -159,16 +159,16 @@ describe('candidateCollector', () => { upsertScoredCandidate(candidates, track, { score: 0.9, - reason: 'high', + source: 'spotify-rec', signals: [], }) upsertScoredCandidate(candidates, track, { score: 0.3, - reason: 'low', + source: 'spotify-rec', signals: [], }) const entry = Array.from(candidates.values())[0] expect(entry.score).toBe(0.9) - expect(entry.reason).toBe('high') + expect(entry.basis.source).toBe('spotify-rec') }) it('drops -Infinity recommendations without inserting', () => { @@ -180,7 +180,7 @@ describe('candidateCollector', () => { upsertScoredCandidate(candidates, track, { score: -Infinity, - reason: 'cross-locale: spanish in non-spanish session', + source: 'spotify-rec', signals: [], }) expect(candidates.size).toBe(0) @@ -192,17 +192,17 @@ describe('candidateCollector', () => { upsertScoredCandidate(candidates, track, { score: 0.5, - reason: 'first', + source: 'spotify-rec', signals: [], }) upsertScoredCandidate(candidates, track, { score: -Infinity, - reason: 'reject', + source: 'spotify-rec', signals: [], }) expect(candidates.size).toBe(1) const entry = Array.from(candidates.values())[0] expect(entry.score).toBe(0.5) - expect(entry.reason).toBe('first') + expect(entry.basis.source).toBe('spotify-rec') }) it('drops NaN recommendations defensively', () => { @@ -211,7 +211,7 @@ describe('candidateCollector', () => { upsertScoredCandidate(candidates, track, { score: Number.NaN, - reason: 'bug', + source: 'spotify-rec', signals: [], }) expect(candidates.size).toBe(0) @@ -227,7 +227,7 @@ describe('candidateCollector', () => { upsertScoredCandidate(candidates, track, { score: 0.5, - reason: 'fallback', + source: 'spotify-rec', signals: [], }) expect(candidates.size).toBe(1) @@ -244,7 +244,7 @@ describe('candidateCollector', () => { upsertScoredCandidate(candidates, track, { score: 0.5, - reason: 'id-fallback', + source: 'spotify-rec', signals: [], }) expect(candidates.size).toBe(1) @@ -257,7 +257,7 @@ describe('candidateCollector', () => { isDuplicateCandidateMock.mockReturnValue(false) calculateRecommendationScoreMock.mockReturnValue({ score: 0.5, - reason: 'test-score', + source: 'spotify-rec', signals: [], }) normalizeTrackKeyMock.mockImplementation( (title?: string, author?: string) => @@ -275,7 +275,7 @@ describe('candidateCollector', () => { title: 'Spotify Result', author: 'Spotify Artist', }), - { score: 0.6, reason: 'spotify' }, + { score: 0.6, source: 'spotify-rec', signals: [] }, ) }, ) @@ -425,7 +425,7 @@ describe('candidateCollector', () => { // Mock score calculation to return -Infinity calculateRecommendationScoreMock.mockReturnValue({ score: -Infinity, - reason: 'filtered', + source: 'spotify-rec', signals: [], }) const queue = createGuildQueue() @@ -531,5 +531,138 @@ describe('candidateCollector', () => { }), ) }) + + it('blocks sertanejo candidates when blockSertanejo=true and tags match', async () => { + collectSpotifyRecommendationCandidatesMock.mockResolvedValue(undefined) + const serTanejoTrack = createTrack({ title: 'Saudade do Nordeste', author: 'Jorge e Mateus' }) + searchSeedCandidatesMock.mockResolvedValue([serTanejoTrack]) + + const getArtistTags = jest.fn().mockResolvedValue(['sertanejo']) + + const result = await collectRecommendationCandidates( + createGuildQueue(), + [createTrack()], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + { getArtistTags }, + true, // blockSertanejo + ) + + expect(result.size).toBe(0) + }) + + it('allows sertanejo candidates when blockSertanejo=false', async () => { + collectSpotifyRecommendationCandidatesMock.mockResolvedValue(undefined) + const serTanejoTrack = createTrack({ title: 'Saudade do Nordeste', author: 'Jorge e Mateus' }) + searchSeedCandidatesMock.mockResolvedValue([serTanejoTrack]) + + const getArtistTags = jest.fn().mockResolvedValue(['sertanejo']) + + const result = await collectRecommendationCandidates( + createGuildQueue(), + [createTrack()], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + { getArtistTags }, + false, // blockSertanejo + ) + + expect(result.size).toBeGreaterThan(0) + }) + + it('does not block sertanejo when tags are empty (fail-open when Last.fm unavailable)', async () => { + collectSpotifyRecommendationCandidatesMock.mockResolvedValue(undefined) + const serTanejoTrack = createTrack({ title: 'Saudade do Nordeste', author: 'Jorge e Mateus' }) + searchSeedCandidatesMock.mockResolvedValue([serTanejoTrack]) + + const getArtistTags = jest.fn().mockResolvedValue([]) // no tags returned + + const result = await collectRecommendationCandidates( + createGuildQueue(), + [createTrack()], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + { getArtistTags }, + true, // blockSertanejo is true but tags are empty — must not block + ) + + expect(result.size).toBeGreaterThan(0) + }) + + it('falls back to empty tags when getArtistTags rejects', async () => { + collectSpotifyRecommendationCandidatesMock.mockResolvedValue(undefined) + const track = createTrack({ title: 'Some Song', author: 'Some Artist' }) + searchSeedCandidatesMock.mockResolvedValue([track]) + + const getArtistTags = jest.fn().mockRejectedValue(new Error('Last.fm down')) + + const result = await collectRecommendationCandidates( + createGuildQueue(), + [createTrack()], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + { getArtistTags }, + true, + ) + + // fail-open: track should not be blocked when tag fetch errors + expect(result.size).toBeGreaterThan(0) + }) }) }) diff --git a/packages/bot/src/utils/music/autoplay/candidateCollector.ts b/packages/bot/src/utils/music/autoplay/candidateCollector.ts index 7e0fdb089..cafe1f6a3 100644 --- a/packages/bot/src/utils/music/autoplay/candidateCollector.ts +++ b/packages/bot/src/utils/music/autoplay/candidateCollector.ts @@ -12,9 +12,22 @@ import { normalizeTrackKey, } from '../queueManipulation' import { isDuplicateCandidate } from './diversitySelector' -import { createArtistTagFetcher, type ArtistTagFetcher } from './artistTagCache' +import { createArtistTagFetcher, hasGenreTag, type ArtistTagFetcher } from './artistTagCache' import type { ScoredTrack } from './diversitySelector' +import type { AutoplayAuditCollector } from './autoplayAudit' +import type { RecommendationBasis, RecommendationSource, RecommendationSignal } from './recommendationBasis.js' +import { serializeBasis } from './recommendationBasis.js' + export type { ScoredTrack } +export type { RecommendationBasis, RecommendationSource, RecommendationSignal } from './recommendationBasis.js' + +export const SERTANEJO_TAGS = [ + 'sertanejo', + 'sertanejo universitário', + 'sertanejo pop', + 'música sertaneja', + 'forró', +] /** * Include a candidate in the pool if it hasn't been played recently @@ -42,32 +55,48 @@ export function shouldIncludeCandidate( export function upsertScoredCandidate( candidates: Map, candidate: Track, - recommendation: { score: number; reason: string }, + scored: { score: number; source: RecommendationSource; signals: RecommendationSignal[] }, + auditCollector?: AutoplayAuditCollector, ): void { - if (!Number.isFinite(recommendation.score)) { + if (!Number.isFinite(scored.score)) { debugLog({ message: 'Autoplay hard-reject', data: { title: candidate.title, author: candidate.author, - score: recommendation.score, - reason: recommendation.reason, + score: scored.score, + source: scored.source, + note: 'empty signals indicate hard-reject at scorer stage (blocked artist, too long, ambient noise, edm mix, spanish locale, disliked, or cross-genre drift)', }, }) + const basis: RecommendationBasis = { source: scored.source, signals: scored.signals } + auditCollector?.recordEvaluated( + candidate, + scored.score, + serializeBasis(basis), + 'rejected', + ) return } + const basis: RecommendationBasis = { source: scored.source, signals: scored.signals } const normalizedKey = normalizeTrackKey(candidate.title, candidate.author) const candidateKey = normalizedKey !== '::' ? normalizedKey : (candidate.id || candidate.url || normalizeTrackKey(candidate.title, candidate.author)) const existing = candidates.get(candidateKey) - if (!existing || recommendation.score > existing.score) { + if (!existing || scored.score > existing.score) { candidates.set(candidateKey, { track: candidate, - score: recommendation.score, - reason: recommendation.reason, + score: scored.score, + basis, }) + auditCollector?.recordEvaluated( + candidate, + scored.score, + serializeBasis(basis), + 'accepted', + ) } } @@ -103,6 +132,7 @@ export async function collectRecommendationCandidates( currentTrackTags?: string[] sessionGenreFamilies?: Set } = {}, + blockSertanejo = false, ): Promise> { const candidates = new Map() const getArtistTags = genreContext.getArtistTags ?? createArtistTagFetcher() @@ -133,14 +163,14 @@ export async function collectRecommendationCandidates( { getArtistTags, currentTrackTags, sessionGenreFamilies }, ) - // Collect from seed track searches (YouTube, Spotify similar) + // Collect from seed track searches (Spotify only — no YouTube fallback to + // avoid language-drift where genre terms like "worship" cause YouTube's + // algorithm to surface Spanish gospel on non-Spanish sessions) for (const seed of seedTracks) { const seedCandidates = await searchSeedCandidates( queue, seed, requestedBy, - replenishCount, - sessionMood, ) for (const candidate of seedCandidates) { if ( @@ -156,7 +186,13 @@ export async function collectRecommendationCandidates( if (dislikedWeight !== undefined && dislikedWeight > 0.5) { continue } - const tags = await getArtistTags(candidate.author) + const tags = await getArtistTags(candidate.author).catch((err: unknown) => { + debugLog({ message: 'candidateCollector: getArtistTags failed', data: { author: candidate.author, err } }) + return [] as string[] + }) + if (blockSertanejo && tags.length > 0 && hasGenreTag(tags, SERTANEJO_TAGS)) { + continue + } const rec = calculateRecommendationScore({ candidate, currentTrack, @@ -176,7 +212,7 @@ export async function collectRecommendationCandidates( sessionGenreFamilies, }, }) - upsertScoredCandidate(candidates, candidate, rec) + upsertScoredCandidate(candidates, candidate, { score: rec.score, source: 'spotify-rec', signals: rec.signals }) } } diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts index 830cb7427..5d65233b4 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts @@ -67,7 +67,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBe(-Infinity) - expect(result.reason).toBe('blocked artist') + expect(result.signals).toEqual([]) }) it('rejects tracks longer than 15 minutes', () => { @@ -83,7 +83,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBe(-Infinity) - expect(result.reason).toBe('track too long') + expect(result.signals).toEqual([]) }) it('rejects ambient/noise content', () => { @@ -99,7 +99,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBe(-Infinity) - expect(result.reason).toBe('ambient/noise content') + expect(result.signals).toEqual([]) }) it('rejects EDM mixes', () => { @@ -113,7 +113,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBe(-Infinity) - expect(result.reason).toBe('dj mix / edm set') + expect(result.signals).toEqual([]) }) it('boosts preferred artists', () => { @@ -130,7 +130,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBeGreaterThan(1) - expect(result.reason).toContain('preferred artist') + expect(result.signals).toContain('preferred artist') }) it('boosts frequent artists (5+ plays)', () => { @@ -150,7 +150,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBeGreaterThan(1) - expect(result.reason).toContain('favourite artist') + expect(result.signals).toContain('favourite artist') }) it('handles explicit dislike with high weight by rejecting', () => { @@ -176,7 +176,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBe(-Infinity) - expect(result.reason).toBe('disliked') + expect(result.signals).toEqual([]) }) it('applies partial penalty for low-weight dislikes', () => { @@ -203,7 +203,7 @@ describe('candidateScorer', () => { dislikedWeights: dislikedWeights, }) - expect(result.reason).toContain('old dislike') + expect(result.signals).toContain('old dislike') expect(result.score).toBeLessThan(1.3) }) @@ -251,9 +251,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBe(-Infinity) - expect(result.reason).toBe( - 'cross-locale: spanish in non-spanish session', - ) + expect(result.signals).toEqual([]) }) it('rejects Spanish gospel via Last.fm artist tags even when title is ambiguous', () => { @@ -297,9 +295,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBe(-Infinity) - expect(result.reason).toBe( - 'cross-locale: spanish in non-spanish session', - ) + expect(result.signals).toEqual([]) }) it('does not reject Spanish candidates when session has Spanish history', () => { @@ -329,7 +325,7 @@ describe('candidateScorer', () => { }) expect(result.score).toBeGreaterThan(0) - expect(result.reason).not.toContain('cross-locale') + expect(result.signals).not.toContain('cross-locale') }) it('hard-rejects candidates that drift from the dominant session genre family', () => { @@ -364,9 +360,7 @@ describe('candidateScorer', () => { }, }) expect(result.score).toBe(-Infinity) - expect(result.reason).toBe( - 'cross-genre: family drift from session', - ) + expect(result.signals).toEqual([]) }) it('does not veto cross-genre when the session has no dominant family yet', () => { @@ -398,7 +392,7 @@ describe('candidateScorer', () => { // Soft genre-family penalty still applies (rap vs metal, no // overlap), but the candidate is not hard-rejected. expect(result.score).not.toBe(-Infinity) - expect(result.reason).toContain('genre family drift') + expect(result.signals).toContain('genre family drift') }) it('keeps candidates within the dominant session genre family', () => { @@ -431,8 +425,8 @@ describe('candidateScorer', () => { }, }) expect(result.score).toBeGreaterThan(0) - expect(result.reason).not.toContain('cross-genre') - expect(result.reason).not.toContain('genre family drift') + expect(result.signals).not.toContain('cross-genre') + expect(result.signals).not.toContain('genre family drift') }) it('boosts deep dive artist tracks', () => { @@ -461,7 +455,7 @@ describe('candidateScorer', () => { sessionMood: mood, }) - expect(result.reason).toContain('deep dive') + expect(result.signals).toContain('deep dive') }) it('boosts long tracks when preferLong is true', () => { @@ -490,7 +484,7 @@ describe('candidateScorer', () => { sessionMood: mood, }) - expect(result.reason).toContain('long track match') + expect(result.signals).toContain('long track match') }) it('boosts short tracks when preferShort is true', () => { @@ -519,7 +513,7 @@ describe('candidateScorer', () => { sessionMood: mood, }) - expect(result.reason).toContain('quick hit match') + expect(result.signals).toContain('quick hit match') }) it('relaxes genre family penalty by 50% during skip storms (recentSkipCount >= 3)', () => { @@ -617,7 +611,7 @@ describe('candidateScorer', () => { sessionMood: mood, }) - expect(result.reason).toContain('restless discovery') + expect(result.signals).toContain('restless discovery') }) }) @@ -682,7 +676,7 @@ describe('candidateScorer', () => { { track: createTrack(), score: 1, - reason: 'test', + signals: [], }, ] spotifyLinkServiceMock.mockResolvedValue(null) @@ -701,7 +695,7 @@ describe('candidateScorer', () => { { track: createTrack(), score: 1, - reason: 'test', + signals: [], }, ] spotifyLinkServiceMock.mockRejectedValue(new Error('Token error')) @@ -720,7 +714,7 @@ describe('candidateScorer', () => { { track: createTrack({ url: 'https://youtube.com/watch?v=123' }), score: 1, - reason: 'test', + signals: [], }, ] spotifyLinkServiceMock.mockResolvedValue('valid-token') @@ -739,7 +733,7 @@ describe('candidateScorer', () => { { track: createTrack(), score: 1, - reason: 'test', + signals: [], }, ] spotifyLinkServiceMock.mockResolvedValue('valid-token') @@ -759,7 +753,7 @@ describe('candidateScorer', () => { { track: createTrack(), score: 1, - reason: 'test', + signals: [], }, ] spotifyLinkServiceMock.mockResolvedValue('valid-token') @@ -780,7 +774,7 @@ describe('candidateScorer', () => { { track: createTrack(), score: 1, - reason: 'test', + signals: [], }, ] spotifyLinkServiceMock.mockResolvedValue('valid-token') @@ -810,7 +804,7 @@ describe('candidateScorer', () => { { track: createTrack(), score: 1, - reason: 'test', + signals: [], }, ] spotifyLinkServiceMock.mockResolvedValue('valid-token') @@ -840,7 +834,7 @@ describe('candidateScorer', () => { { track: createTrack(), score: 1, - reason: 'test', + signals: [], }, ] spotifyLinkServiceMock.mockResolvedValue('valid-token') @@ -866,7 +860,7 @@ describe('candidateScorer', () => { }) it('applies tempo drastic change penalty when delta exceeds 40 BPM', async () => { - const tracks = [{ track: createTrack(), score: 1, reason: 'test' }] + const tracks = [{ track: createTrack(), score: 1, signals: [] }] spotifyLinkServiceMock.mockResolvedValue('valid-token') getBatchAudioFeaturesMock.mockResolvedValue( new Map([ @@ -884,7 +878,7 @@ describe('candidateScorer', () => { }) it('boosts track with high acousticness feature value', async () => { - const tracks = [{ track: createTrack(), score: 1, reason: 'test' }] + const tracks = [{ track: createTrack(), score: 1, signals: [] }] spotifyLinkServiceMock.mockResolvedValue('valid-token') getBatchAudioFeaturesMock.mockResolvedValue( new Map([ @@ -902,7 +896,7 @@ describe('candidateScorer', () => { }) it('penalizes extreme acousticness swing when candidate is not acoustic', async () => { - const tracks = [{ track: createTrack(), score: 1, reason: 'test' }] + const tracks = [{ track: createTrack(), score: 1, signals: [] }] spotifyLinkServiceMock.mockResolvedValue('valid-token') getBatchAudioFeaturesMock.mockResolvedValue( new Map([ @@ -920,7 +914,7 @@ describe('candidateScorer', () => { }) it('applies continuity bonus when both current and candidate are acoustic', async () => { - const tracks = [{ track: createTrack(), score: 1, reason: 'test' }] + const tracks = [{ track: createTrack(), score: 1, signals: [] }] spotifyLinkServiceMock.mockResolvedValue('valid-token') getBatchAudioFeaturesMock.mockResolvedValue( new Map([ @@ -938,7 +932,7 @@ describe('candidateScorer', () => { }) it('penalizes acoustic candidate in a non-acoustic session', async () => { - const tracks = [{ track: createTrack(), score: 1, reason: 'test' }] + const tracks = [{ track: createTrack(), score: 1, signals: [] }] spotifyLinkServiceMock.mockResolvedValue('valid-token') getBatchAudioFeaturesMock.mockResolvedValue( new Map([ @@ -963,7 +957,7 @@ describe('candidateScorer', () => { url: 'https://open.spotify.com/track/lowid', }), score: 0.5, - reason: 'test', + signals: [], }, { track: createTrack({ @@ -971,7 +965,7 @@ describe('candidateScorer', () => { url: 'https://open.spotify.com/track/highid', }), score: 2, - reason: 'test', + signals: [], }, ] spotifyLinkServiceMock.mockResolvedValue('valid-token') diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.ts index 7f8266e14..cafedd129 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.ts @@ -3,6 +3,7 @@ import type { SpotifyAudioFeatures } from '../../../spotify/spotifyApi' import { getBatchAudioFeatures } from '../../../spotify/spotifyApi' import { spotifyLinkService } from '@lucky/shared/services' import type { SessionMood } from './sessionMood' +import type { RecommendationSignal } from './recommendationBasis.js' import { cleanAuthor } from '../searchQueryCleaner' import { detectSpanishMarkers } from '../languageHeuristics' import { normalizeText, normalizeTrackKey } from '../queueManipulation' @@ -75,6 +76,7 @@ const GENRE_FAMILIES = { jazz_classical: ['jazz', 'classical', 'orchestral'], world: ['afrobeat', 'desi', 'bhangra'], ambient_chill: ['lofi', 'chillwave', 'downtempo', 'ambient'], + gospel_christian: ['gospel', 'christian', 'christian music', 'contemporary christian music', 'ccm', 'worship', 'christian rock', 'christian pop', 'praise & worship', 'religious'], } const AMBIENT_NOISE_RE = @@ -150,7 +152,7 @@ export interface ScoringContext { export function calculateRecommendationScore( ctx: ScoringContext, -): { score: number; reason: string } { +): { score: number; signals: RecommendationSignal[] } { const { candidate, currentTrack, @@ -175,7 +177,7 @@ export function calculateRecommendationScore( const candidateArtistKey = normalizeText(cleanAuthor(candidate.author)) if (blockedArtistKeys.has(candidateArtistKey)) { - return { score: -Infinity, reason: 'blocked artist' } + return { score: -Infinity, signals: [] } } const MAX_CANDIDATE_DURATION_MS = 15 * 60 * 1000 @@ -183,19 +185,19 @@ export function calculateRecommendationScore( candidate.durationMS && candidate.durationMS > MAX_CANDIDATE_DURATION_MS ) { - return { score: -Infinity, reason: 'track too long' } + return { score: -Infinity, signals: [] } } const candidateTitle = candidate.title ?? '' if (AMBIENT_NOISE_RE.test(candidateTitle)) { - return { score: -Infinity, reason: 'ambient/noise content' } + return { score: -Infinity, signals: [] } } if (EDM_MIX_RE.test(candidateTitle)) { - return { score: -Infinity, reason: 'dj mix / edm set' } + return { score: -Infinity, signals: [] } } let score = 1 - const reasons: string[] = [] + const signals: RecommendationSignal[] = [] // Cross-locale veto: if the session has shown no Spanish content but the // candidate looks Spanish (Spanish-distinct accents/stopwords/gospel @@ -206,10 +208,7 @@ export function calculateRecommendationScore( if (sessionMood !== null && sessionMood.dominantLocale === null) { const candidateText = `${candidateTitle} ${candidate.author ?? ''}` if (detectSpanishMarkers(candidateText, candidateTags)) { - return { - score: -Infinity, - reason: 'cross-locale: spanish in non-spanish session', - } + return { score: -Infinity, signals: [] } } } @@ -232,54 +231,51 @@ export function calculateRecommendationScore( } } if (!intersects) { - return { - score: -Infinity, - reason: 'cross-genre: family drift from session', - } + return { score: -Infinity, signals: [] } } } } if (preferredArtistKeys.has(candidateArtistKey)) { score += SCORE_PREFERRED_ARTIST - reasons.push('preferred artist') + signals.push('preferred artist') } const freq = artistFrequency.get(candidateArtistKey) ?? 0 if (freq >= 5) { score += SCORE_SAME_ARTIST - reasons.push('favourite artist') + signals.push('favourite artist') } else if (freq >= 3) { score += SCORE_POPULAR_ARTIST - reasons.push('liked artist') + signals.push('liked artist') } else if (freq >= 1) { score += SCORE_POPULAR_ARTIST / 2 - reasons.push('known artist') + signals.push('known artist') } const candidateKey = normalizeTrackKey(candidate.title, candidate.author) const likedWeight = likedWeights.get(candidateKey) if (likedWeight !== undefined) { score += SCORE_LIKED_WEIGHT_MULTIPLIER * likedWeight - reasons.push('liked track') + signals.push('liked track') } const dislikedWeight = dislikedWeights.get(candidateKey) if (dislikedWeight !== undefined) { if (dislikedWeight > DISLIKE_WEIGHT_THRESHOLD) { - return { score: -Infinity, reason: 'disliked' } + return { score: -Infinity, signals: [] } } score -= Math.abs(SCORE_DISLIKED_PENALTY) * dislikedWeight - reasons.push('old dislike') + signals.push('old dislike') } if (implicitDislikeKeys.has(candidateKey)) { score += SCORE_IMPLICIT_DISLIKE - reasons.push('skipped before') + signals.push('skipped before') } if (implicitLikeKeys.has(candidateKey)) { score += SCORE_IMPLICIT_LIKE - reasons.push('completed before') + signals.push('completed before') } const deepDiveArtist = sessionMood?.deepDiveArtist @@ -296,15 +292,15 @@ export function calculateRecommendationScore( ) if (titleSim > TITLE_SIM_THRESHOLD) { score += SCORE_TITLE_SIM_BONUS - reasons.push('album match') + signals.push('album match') } if (isDeepDive) { score += SCORE_TITLE_SIM_BONUS - reasons.push('deep-dive artist') + signals.push('deep-dive artist') } } else if (!skipNoveltyBoost && !recentArtists.has(candidateArtist) && !isDeepDive) { score += SCORE_DURATION_MATCH - reasons.push('session novelty') + signals.push('session novelty') } if ( candidate.source === currentTrack.source && @@ -312,7 +308,7 @@ export function calculateRecommendationScore( ) { score += SCORE_RECENT_ARTIST } else if (candidate.source && candidate.source !== currentTrack.source) { - reasons.push('source variety') + signals.push('source variety') } const tokenScore = sharedTitleTokenScore( candidate.title, @@ -320,7 +316,7 @@ export function calculateRecommendationScore( ) score += tokenScore if (tokenScore > 0) { - reasons.push('similar title mood') + signals.push('similar title mood') } if ( currentTrack.durationMS && @@ -330,7 +326,7 @@ export function calculateRecommendationScore( const ratio = candidate.durationMS / currentTrack.durationMS if (ratio >= DURATION_RATIO_TIGHT_LOW && ratio <= DURATION_RATIO_TIGHT_HIGH) { score += SCORE_DURATION_RATIO_TIGHT - reasons.push('similar energy') + signals.push('similar energy') } else if (ratio >= DURATION_RATIO_LOOSE_LOW && ratio <= DURATION_RATIO_LOOSE_HIGH) { score += SCORE_DURATION_RATIO_LOOSE } @@ -338,7 +334,7 @@ export function calculateRecommendationScore( if (candidate.durationMS && candidate.durationMS > 7 * 60 * 1000) { score += SCORE_FREQUENT_ARTIST - reasons.push('long track penalty') + signals.push('long track penalty') } if (sessionMood) { @@ -348,28 +344,28 @@ export function calculateRecommendationScore( candidateArtist === sessionMood.deepDiveArtist ) { score += SCORE_DURATION_MATCH - reasons.push('deep dive') + signals.push('deep dive') } if (sessionMood.preferLong && durationMs > 0) { const durationBonus = 0.15 * Math.tanh((durationMs - 300_000) / 60_000) score += durationBonus - if (durationBonus > SCORE_DURATION_BONUS_THRESHOLD) reasons.push('long track match') + if (durationBonus > SCORE_DURATION_BONUS_THRESHOLD) signals.push('long track match') } if (sessionMood.preferShort && durationMs > 0 && durationMs < 180_000) { score += SCORE_ACOUSTICNESS_MATCH - reasons.push('quick hit match') + signals.push('quick hit match') } if (sessionMood.restless) { if (!recentArtists.has(candidateArtist)) { score += SCORE_ACOUSTICNESS_MATCH - reasons.push('restless discovery') + signals.push('restless discovery') } } } if (candidate.source === 'spotify') { score += SCORE_SPOTIFY_PREFERRED - reasons.push('spotify preferred') + signals.push('spotify preferred') } // Soft genre-family penalty (formerly inside enrichWithAudioFeatures via @@ -390,7 +386,7 @@ export function calculateRecommendationScore( if (familyPenalty !== 0) { score += familyPenalty if (familyPenalty <= -0.3) { - reasons.push('genre family drift') + signals.push('genre family drift') } } } @@ -401,7 +397,7 @@ export function calculateRecommendationScore( ) ) { score += SCORE_FREQUENT_ARTIST - reasons.push('version variant') + signals.push('version variant') } if ( @@ -412,13 +408,13 @@ export function calculateRecommendationScore( /\(\d{1,2}:\d{2}:\d{2}\)/.test(candidate.title ?? '') ) { score += SCORE_BLOCKED_ARTIST - reasons.push('low quality upload') + signals.push('low quality upload') } if (autoplayMode === 'discover') { if (!recentArtists.has(candidateArtist)) { score += SCORE_IMPLICIT_LIKE - reasons.push('discovery boost') + signals.push('discovery boost') } if (recentArtists.has(candidateArtist)) { score += SCORE_FREQUENT_ARTIST @@ -431,15 +427,14 @@ export function calculateRecommendationScore( const ratio = candidate.durationMS / currentTrack.durationMS if (ratio >= 0.9 && ratio <= 1.1) { score += SCORE_LIKED_ARTIST_TEMPO - reasons.push('energy match') + signals.push('energy match') } } } return { score, - reason: - reasons.length > 0 ? reasons.join(' • ') : 'balanced autoplay pick', + signals, } } diff --git a/packages/bot/src/utils/music/autoplay/counters.spec.ts b/packages/bot/src/utils/music/autoplay/counters.spec.ts index 2e8d733d4..289c08e6a 100644 --- a/packages/bot/src/utils/music/autoplay/counters.spec.ts +++ b/packages/bot/src/utils/music/autoplay/counters.spec.ts @@ -14,12 +14,9 @@ import { clearAllAutoplayCounters, } from './counters' -const debugLogMock = jest.fn() -const errorLogMock = jest.fn() - jest.mock('@lucky/shared/utils', () => ({ - debugLog: (...args: unknown[]) => debugLogMock(...args), - errorLog: (...args: unknown[]) => errorLogMock(...args), + debugLog: jest.fn(), + errorLog: jest.fn(), })) jest.mock('@lucky/shared/services', () => { @@ -43,74 +40,37 @@ describe('autoplay/counters', () => { jest.clearAllMocks() autoplayCounters.clear() const { guildSettingsService } = require('@lucky/shared/services') - mockGetAutoplayCounter = - guildSettingsService.getAutoplayCounter as jest.Mock - mockIncrementAutoplayCounter = - guildSettingsService.incrementAutoplayCounter as jest.Mock - mockResetAutoplayCounter = - guildSettingsService.resetAutoplayCounter as jest.Mock - mockClearAllAutoplayCounters = - guildSettingsService.clearAllAutoplayCounters as jest.Mock + mockGetAutoplayCounter = guildSettingsService.getAutoplayCounter as jest.Mock + mockIncrementAutoplayCounter = guildSettingsService.incrementAutoplayCounter as jest.Mock + mockResetAutoplayCounter = guildSettingsService.resetAutoplayCounter as jest.Mock + mockClearAllAutoplayCounters = guildSettingsService.clearAllAutoplayCounters as jest.Mock }) afterEach(() => { autoplayCounters.clear() - jest.clearAllMocks() }) describe('getAutoplayCount', () => { test('returns 0 when guild has no count', async () => { mockGetAutoplayCounter.mockResolvedValue(null) - - const result = await getAutoplayCount('guild-1') - - expect(result).toBe(0) + expect(await getAutoplayCount('guild-1')).toBe(0) }) test('returns count from Redis when available', async () => { mockGetAutoplayCounter.mockResolvedValue({ count: 42 }) - - const result = await getAutoplayCount('guild-1') - - expect(result).toBe(42) + expect(await getAutoplayCount('guild-1')).toBe(42) }) - test('returns count from memory map when Redis is unavailable', async () => { + test('falls back to memory map when Redis fails', async () => { autoplayCounters.set('guild-1', 25) mockGetAutoplayCounter.mockRejectedValue(new Error('Redis error')) - - const result = await getAutoplayCount('guild-1') - - expect(result).toBe(25) - }) - - test('returns 0 when both Redis and memory are empty', async () => { - mockGetAutoplayCounter.mockRejectedValue(new Error('Redis error')) - - const result = await getAutoplayCount('guild-2') - - expect(result).toBe(0) - }) - - test('logs error when Redis fails', async () => { - mockGetAutoplayCounter.mockRejectedValue(new Error('Redis error')) - - await getAutoplayCount('guild-1') - - expect(errorLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Error getting autoplay count:', - }), - ) + expect(await getAutoplayCount('guild-1')).toBe(25) }) - test('prefers Redis value over memory when both exist', async () => { + test('prefers Redis value over in-memory when both exist', async () => { autoplayCounters.set('guild-1', 10) mockGetAutoplayCounter.mockResolvedValue({ count: 50 }) - - const result = await getAutoplayCount('guild-1') - - expect(result).toBe(50) + expect(await getAutoplayCount('guild-1')).toBe(50) }) }) @@ -118,86 +78,20 @@ describe('autoplay/counters', () => { test('increments count in memory', async () => { mockIncrementAutoplayCounter.mockResolvedValue(undefined) autoplayCounters.set('guild-1', 5) - const result = await incrementAutoplayCount('guild-1', 1) - expect(result).toBe(6) expect(autoplayCounters.get('guild-1')).toBe(6) }) - test('increments by custom amount', async () => { - mockIncrementAutoplayCounter.mockResolvedValue(undefined) - autoplayCounters.set('guild-1', 10) - - const result = await incrementAutoplayCount('guild-1', 5) - - expect(result).toBe(15) - }) - - test('defaults to increment of 1', async () => { - mockIncrementAutoplayCounter.mockResolvedValue(undefined) - autoplayCounters.set('guild-1', 8) - - const result = await incrementAutoplayCount('guild-1') - - expect(result).toBe(9) - }) - test('initializes counter from 0 if not present', async () => { mockIncrementAutoplayCounter.mockResolvedValue(undefined) - - const result = await incrementAutoplayCount('guild-1', 1) - - expect(result).toBe(1) - }) - - test('calls guildSettingsService.incrementAutoplayCounter', async () => { - mockIncrementAutoplayCounter.mockResolvedValue(undefined) - - await incrementAutoplayCount('guild-1', 1) - - expect(mockIncrementAutoplayCounter).toHaveBeenCalledWith('guild-1') + expect(await incrementAutoplayCount('guild-1', 1)).toBe(1) }) - test('logs debug message on success', async () => { - mockIncrementAutoplayCounter.mockResolvedValue(undefined) + test('returns unchanged memory count when service fails', async () => { autoplayCounters.set('guild-1', 5) - - await incrementAutoplayCount('guild-1', 1) - - expect(debugLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Incremented autoplay count for guild guild-1', - data: expect.objectContaining({ - newCount: 6, - }), - }), - ) - }) - - test('returns memory count when service fails', async () => { - autoplayCounters.set('guild-1', 5) - mockIncrementAutoplayCounter.mockRejectedValue( - new Error('Service error'), - ) - - const result = await incrementAutoplayCount('guild-1', 2) - - expect(result).toBe(5) - }) - - test('logs error when increment fails', async () => { - mockIncrementAutoplayCounter.mockRejectedValue( - new Error('Service error'), - ) - - await incrementAutoplayCount('guild-1', 1) - - expect(errorLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Error incrementing autoplay count:', - }), - ) + mockIncrementAutoplayCounter.mockRejectedValue(new Error('Service error')) + expect(await incrementAutoplayCount('guild-1', 2)).toBe(5) }) }) @@ -205,111 +99,33 @@ describe('autoplay/counters', () => { test('sets counter to 0 in memory', async () => { mockResetAutoplayCounter.mockResolvedValue(undefined) autoplayCounters.set('guild-1', 100) - await resetAutoplayCount('guild-1') - expect(autoplayCounters.get('guild-1')).toBe(0) }) - test('calls guildSettingsService.resetAutoplayCounter', async () => { - mockResetAutoplayCounter.mockResolvedValue(undefined) - - await resetAutoplayCount('guild-1') - - expect(mockResetAutoplayCounter).toHaveBeenCalledWith('guild-1') - }) - - test('logs debug message on success', async () => { - mockResetAutoplayCounter.mockResolvedValue(undefined) - - await resetAutoplayCount('guild-1') - - expect(debugLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Reset autoplay count for guild guild-1', - }), - ) - }) - - test('logs error when reset fails', async () => { - mockResetAutoplayCounter.mockRejectedValue( - new Error('Service error'), - ) - - await resetAutoplayCount('guild-1') - - expect(errorLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Error resetting autoplay count:', - }), - ) - }) - - test('resets multiple guilds independently', async () => { + test('resets guilds independently without affecting others', async () => { mockResetAutoplayCounter.mockResolvedValue(undefined) autoplayCounters.set('guild-1', 50) autoplayCounters.set('guild-2', 75) - await resetAutoplayCount('guild-1') - expect(autoplayCounters.get('guild-1')).toBe(0) expect(autoplayCounters.get('guild-2')).toBe(75) }) }) describe('clearAllAutoplayCounters', () => { - test('clears memory map', async () => { + test('clears all guild counters from memory', async () => { mockClearAllAutoplayCounters.mockResolvedValue(undefined) autoplayCounters.set('guild-1', 10) autoplayCounters.set('guild-2', 20) - await clearAllAutoplayCounters() - expect(autoplayCounters.size).toBe(0) }) - test('calls guildSettingsService.clearAllAutoplayCounters', async () => { - mockClearAllAutoplayCounters.mockResolvedValue(undefined) - - await clearAllAutoplayCounters() - - expect(mockClearAllAutoplayCounters).toHaveBeenCalled() - }) - - test('logs debug message on success', async () => { - mockClearAllAutoplayCounters.mockResolvedValue(undefined) - - await clearAllAutoplayCounters() - - expect(debugLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Cleared all autoplay counters', - }), - ) - }) - - test('logs error when clear fails', async () => { - mockClearAllAutoplayCounters.mockRejectedValue( - new Error('Service error'), - ) - - await clearAllAutoplayCounters() - - expect(errorLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Error clearing autoplay counters:', - }), - ) - }) - - test('clears map even if service fails', async () => { - mockClearAllAutoplayCounters.mockRejectedValue( - new Error('Service error'), - ) + test('clears memory map even if service fails', async () => { + mockClearAllAutoplayCounters.mockRejectedValue(new Error('Service error')) autoplayCounters.set('guild-1', 10) - await clearAllAutoplayCounters() - expect(autoplayCounters.size).toBe(0) }) }) diff --git a/packages/bot/src/utils/music/autoplay/diversitySelector.spec.ts b/packages/bot/src/utils/music/autoplay/diversitySelector.spec.ts index 53d8945b4..ad1202cfe 100644 --- a/packages/bot/src/utils/music/autoplay/diversitySelector.spec.ts +++ b/packages/bot/src/utils/music/autoplay/diversitySelector.spec.ts @@ -247,6 +247,54 @@ describe('diversitySelector', () => { ), ).toBe(false) }) + + test('detects remastered version as duplicate of base title in excludedKeys', () => { + // "bohemian rhapsody" is excluded; "bohemian rhapsody - remastered" should match + const excludedUrls = new Set() + const excludedKeys = new Set(['bohemian rhapsody']) + + const track: Partial = { + url: 'https://open.spotify.com/track/xyz', + title: 'Bohemian Rhapsody - Remastered', + author: 'Queen', + } + + expect( + isDuplicateCandidate(track as Track, excludedUrls, excludedKeys), + ).toBe(true) + }) + + test('detects live version as duplicate of base title', () => { + const excludedUrls = new Set() + const excludedKeys = new Set(['hotel california']) + + const track: Partial = { + url: 'https://open.spotify.com/track/abc', + title: 'Hotel California - Live', + author: 'Eagles', + } + + expect( + isDuplicateCandidate(track as Track, excludedUrls, excludedKeys), + ).toBe(true) + }) + + test('does not strip mid-title variant words — only suffix', () => { + // "live" in the middle of the title should not be stripped + const excludedUrls = new Set() + const excludedKeys = new Set(['live and let die']) + + const track: Partial = { + url: 'https://open.spotify.com/track/def', + title: 'Live and Let Die - Remastered', + author: 'Wings', + } + + // "live and let die" after stripping " - Remastered" → still matches + expect( + isDuplicateCandidate(track as Track, excludedUrls, excludedKeys), + ).toBe(true) + }) }) describe('selectDiverseCandidates', () => { @@ -261,7 +309,7 @@ describe('diversitySelector', () => { title: 'Song 1', } as Track, score: 0.9, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], [ @@ -273,7 +321,7 @@ describe('diversitySelector', () => { title: 'Song 2', } as Track, score: 0.8, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], [ @@ -285,7 +333,7 @@ describe('diversitySelector', () => { title: 'Song 3', } as Track, score: 0.7, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], ]) @@ -310,7 +358,7 @@ describe('diversitySelector', () => { title: 'Song 1', } as Track, score: 0.9, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], [ @@ -322,7 +370,7 @@ describe('diversitySelector', () => { title: 'Song 2', } as Track, score: 0.8, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], [ @@ -334,7 +382,7 @@ describe('diversitySelector', () => { title: 'Song 3', } as Track, score: 0.7, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], ]) @@ -356,7 +404,7 @@ describe('diversitySelector', () => { title: 'Song 1', } as Track, score: 0.9, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], [ @@ -368,7 +416,7 @@ describe('diversitySelector', () => { title: 'Song 2', } as Track, score: 0.8, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], [ @@ -380,7 +428,7 @@ describe('diversitySelector', () => { title: 'Song 3', } as Track, score: 0.7, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], ]) @@ -409,7 +457,7 @@ describe('diversitySelector', () => { title: 'Song 1', } as Track, score: 0.9, - reason: 'similar', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], ]) @@ -430,7 +478,7 @@ describe('diversitySelector', () => { title: 'Song 1', } as Track, score: 0.5, - reason: 'low', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], [ @@ -442,7 +490,7 @@ describe('diversitySelector', () => { title: 'Song 2', } as Track, score: 0.95, - reason: 'high', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ], ]) @@ -470,7 +518,7 @@ describe('diversitySelector', () => { raw: albumName ? { album: { name: albumName } } : {}, } as unknown as Track, score, - reason: 'test', + basis: { source: 'spotify-rec' as const, signals: [] }, } } diff --git a/packages/bot/src/utils/music/autoplay/diversitySelector.ts b/packages/bot/src/utils/music/autoplay/diversitySelector.ts index d95ac5cd6..c496d7300 100644 --- a/packages/bot/src/utils/music/autoplay/diversitySelector.ts +++ b/packages/bot/src/utils/music/autoplay/diversitySelector.ts @@ -4,16 +4,18 @@ import { trackHistoryService } from '@lucky/shared/services' import { extractSongCore, cleanTitle, cleanAuthor } from '../searchQueryCleaner' import { calculateStringSimilarity } from '../duplicateDetection/similarityChecker' import { markAsAutoplayTrack } from '../queueManipulation' +import type { RecommendationBasis } from './recommendationBasis.js' +import { serializeBasis } from './recommendationBasis.js' interface ScoredTrack { track: Track score: number - reason: string + basis: RecommendationBasis } const MAX_TRACKS_PER_ARTIST = 2 const MAX_TRACKS_PER_SOURCE = 3 -const FUZZY_TITLE_THRESHOLD = 0.82 +const FUZZY_TITLE_THRESHOLD = 0.75 function randomJitter(max: number): number { return Math.random() * max // NOSONAR - non-cryptographic jitter for diversity selection @@ -55,9 +57,72 @@ function normalizeTitleOnly(title?: string): string { } function stripFeaturing(author: string): string { - return author - .split(/\s+(?:feat|ft)\.?(?:\s|$)/i)[0] // NOSONAR - bounded alternation, no catastrophic backtracking - .replace(/\s*\([^)]*feat[^)]*\)\s*/gi, '') // NOSONAR - [^)]* bounded by paren close, no backtracking + const lower = author.toLowerCase() + let cut = author.length + for (const marker of [' feat ', ' feat.', ' ft ', ' ft.']) { + const idx = lower.indexOf(marker) + if (idx >= 0 && idx < cut) cut = idx + } + let result = author.slice(0, cut) + // Remove parenthetical groups containing "feat" or "ft" + let i = 0 + while (i < result.length) { + const open = result.indexOf('(', i) + if (open === -1) break + const close = result.indexOf(')', open + 1) + if (close === -1) break + const inner = result.slice(open + 1, close).toLowerCase() + if (inner.includes('feat') || inner.startsWith('ft ') || inner.startsWith('ft.')) { + result = (result.slice(0, open) + result.slice(close + 1)).trim() + i = 0 + } else { + i = close + 1 + } + } + return result.trim() +} + +const VARIANT_KEYWORDS = [ + 'remastered', 'remaster', 'remixed', 'remix', + 'radio edit', 'extended mix', 'club mix', 'vip mix', + 'edit', 'version', 'acoustic', 'live', 'cover', +] + +function startsWithYear(s: string): boolean { + return s.length >= 5 && s[4] === ' ' && + s[0] >= '0' && s[0] <= '9' && + s[1] >= '0' && s[1] <= '9' && + s[2] >= '0' && s[2] <= '9' && + s[3] >= '0' && s[3] <= '9' +} + +function stripVariantSuffix(title: string): string { + const lower = title.toLowerCase() + const trimmedLower = lower.trimEnd() + + // Strip parenthetical variant suffix at end: (Remastered) or [2015 Live] + for (const [openChar, closeChar] of [['(', ')'], ['[', ']']] as [string, string][]) { + if (trimmedLower[trimmedLower.length - 1] !== closeChar) continue + const lastOpen = trimmedLower.lastIndexOf(openChar) + if (lastOpen < 0) continue + let inner = trimmedLower.slice(lastOpen + 1, trimmedLower.length - 1).trim() + if (startsWithYear(inner)) inner = inner.slice(5) + if (VARIANT_KEYWORDS.some((v) => inner === v || inner.startsWith(v + ' '))) { + return title.slice(0, lastOpen).trimEnd() + } + } + + // Strip dash-prefixed variant suffix at end: - Remastered or – 2015 Live + for (const sep of [' - ', ' – ']) { + const idx = lower.lastIndexOf(sep) + if (idx < 0) continue + let rest = lower.slice(idx + sep.length).trimEnd() + if (startsWithYear(rest)) rest = rest.slice(5) + if (VARIANT_KEYWORDS.some((v) => rest === v || rest.startsWith(v + ' '))) { + return title.slice(0, idx).trimEnd() + } + } + return title } function getAllHistoryTracks(queue: GuildQueue): Track[] { @@ -116,6 +181,8 @@ export function buildExcludedKeys( for (const t of allTracks) { keys.push(normalizeTrackKey(t.title, t.author)) keys.push(normalizeTitleOnly(t.title)) + const variantStripped = stripVariantSuffix(t.title ?? '') + if (variantStripped) keys.push(normalizeTitleOnly(variantStripped)) const core = extractSongCore(t.title ?? '', t.author) if (core) keys.push(normalizeText(core)) } @@ -139,6 +206,8 @@ export function isDuplicateCandidate( if (core !== null && excludedKeys.has(normalizeText(core))) return true const candidateTitle = normalizeTitleOnly(track.title) + const candidateTitleStripped = normalizeTitleOnly(stripVariantSuffix(track.title ?? '')) + if (candidateTitleStripped && excludedKeys.has(candidateTitleStripped)) return true if (candidateTitle.length >= 5) { for (const key of excludedKeys) { if (key.includes('::') || key.length < 5) continue @@ -220,7 +289,7 @@ export async function addSelectedTracks( const historyWrites: Promise[] = [] for (const candidate of selected) { - markAsAutoplayTrack(candidate.track, candidate.reason, requestedById) + markAsAutoplayTrack(candidate.track, serializeBasis(candidate.basis), requestedById) queue.addTrack(candidate.track) // Update local exclusion sets for this replenish call excludedUrls.add(candidate.track.url) diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeder.spec.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeder.spec.ts index 27a76e283..7fcd898a4 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeder.spec.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeder.spec.ts @@ -26,6 +26,7 @@ jest.mock('@lucky/shared/services', () => ({ })) jest.mock('./lastFmSeeds', () => ({ + LASTFM_SEED_COUNT: 15, consumeLastFmSeedSlice: (...args: unknown[]) => consumeLastFmSeedSliceMock(...args), consumeBlendedSeedSlice: (...args: unknown[]) => consumeBlendedSeedSliceMock(...args), isLovedSeed: (...args: unknown[]) => isLovedSeedMock(...args), @@ -49,9 +50,12 @@ jest.mock('./candidateScorer', () => ({ calculateRecommendationScore: (...args: unknown[]) => calculateRecommendationScoreMock(...args), })) -jest.mock('../queueManipulation', () => ({ +jest.mock('./candidateCollector', () => ({ shouldIncludeCandidate: (...args: unknown[]) => shouldIncludeCandidateMock(...args), upsertScoredCandidate: (...args: unknown[]) => upsertScoredCandidateMock(...args), +})) + +jest.mock('../queueManipulation', () => ({ normalizeTrackKey: (...args: unknown[]) => normalizeTrackKeyMock(...args), })) @@ -145,7 +149,7 @@ describe('collectLastFmCandidates', () => { cleanTitleMock.mockImplementation((s: unknown) => s) normalizeTrackKeyMock.mockReturnValue('normalized-key') shouldIncludeCandidateMock.mockReturnValue(true) - calculateRecommendationScoreMock.mockReturnValue({ score: 0.5, reason: 'test' }) + calculateRecommendationScoreMock.mockReturnValue({ score: 0.5, signals: [] }) isLovedSeedMock.mockReturnValue(false) getSimilarTracksMock.mockResolvedValue([]) getTagTopTracksMock.mockResolvedValue([]) @@ -179,7 +183,7 @@ describe('collectLastFmCandidates', () => { new Set(), new Set(), createTrack(), new Set(), candidates, ) - expect(consumeLastFmSeedSliceMock).toHaveBeenCalledWith('user-1', 3) + expect(consumeLastFmSeedSliceMock).toHaveBeenCalledWith('user-1', 15) }) it('uses blended seed when multiple VC members are linked', async () => { @@ -273,8 +277,8 @@ describe('collectLastFmCandidates', () => { // seed track + similar track both call upsertScoredCandidate expect(upsertScoredCandidateMock).toHaveBeenCalledTimes(2) const similarCall = upsertScoredCandidateMock.mock.calls[1] - const reason = (similarCall?.[2] as { reason: string })?.reason - expect(reason).toContain('similar to your taste') + const source = (similarCall?.[2] as { source: string })?.source + expect(source).toBe('lastfm-similar') }) it('skips excluded tracks in similar-tracks loop (line 155 continue)', async () => { @@ -338,7 +342,7 @@ describe('collectLastFmCandidates', () => { expect(getTagTopTracksMock).toHaveBeenCalledWith('rock', 20) const calls = upsertScoredCandidateMock.mock.calls const genreCall = calls.find( - (c) => ((c[2] as { reason: string })?.reason ?? '').includes('genre fallback'), + (c) => (c[2] as { source: string })?.source === 'lastfm-genre-fallback', ) expect(genreCall).toBeDefined() }) @@ -365,7 +369,7 @@ describe('collectLastFmCandidates', () => { const calls = upsertScoredCandidateMock.mock.calls const genreCall = calls.find( - (c) => ((c[2] as { reason: string })?.reason ?? '').includes('genre fallback'), + (c) => (c[2] as { source: string })?.source === 'lastfm-genre-fallback', ) expect(genreCall).toBeUndefined() }) @@ -389,7 +393,7 @@ describe('collectLastFmCandidates', () => { new Set(), new Set(), createTrack(), new Set(), candidates, ) - expect(consumeLastFmSeedSliceMock).toHaveBeenCalledWith('user-1', 3) + expect(consumeLastFmSeedSliceMock).toHaveBeenCalledWith('user-1', 15) expect(consumeBlendedSeedSliceMock).not.toHaveBeenCalled() }) @@ -432,7 +436,7 @@ describe('collectLastFmCandidates', () => { ) const genreCall = upsertScoredCandidateMock.mock.calls.find( - (c) => ((c[2] as { reason: string })?.reason ?? '').includes('genre fallback'), + (c) => (c[2] as { source: string })?.source === 'lastfm-genre-fallback', ) expect(genreCall).toBeUndefined() }) diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts index 0d18fd49e..6513bd75c 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts @@ -5,6 +5,7 @@ import { consumeLastFmSeedSlice, consumeBlendedSeedSlice, isLovedSeed, + LASTFM_SEED_COUNT, } from './lastFmSeeds' import { getSimilarTracks, getTagTopTracks } from '../../../lastfm' import { createArtistTagFetcher, type ArtistTagFetcher } from './artistTagCache' @@ -15,17 +16,19 @@ import { import type { SessionMood } from './sessionMood' import { calculateRecommendationScore } from './candidateScorer' import { - shouldIncludeCandidate, - upsertScoredCandidate, normalizeTrackKey, } from '../queueManipulation' +import { + shouldIncludeCandidate, + upsertScoredCandidate, +} from './candidateCollector' import type { QueueMetadata } from '../../../types/QueueMetadata' -import type { ScoredTrack } from './diversitySelector'; +import type { ScoredTrack } from './diversitySelector' +import type { AutoplayAuditCollector } from './autoplayAudit' -const LASTFM_SEED_COUNT = 3 const LASTFM_SCORE_BOOST = 0.20 const LOVED_SEED_EXTRA_BOOST = 0.10 -const MAX_SIMILAR_LOOKUPS = 5 +const MAX_SIMILAR_LOOKUPS = 15 const SEARCH_RESULTS_LIMIT = 8 const MAX_AUTOPLAY_DURATION_MS = 10 * 60 * 1000 const AUTOPLAY_BUFFER_SIZE = 8 @@ -53,6 +56,7 @@ export async function collectLastFmCandidates( currentTrackTags?: string[] sessionGenreFamilies?: Set } = {}, + auditCollector?: AutoplayAuditCollector, ): Promise { const metadata = queue.metadata as QueueMetadata const vcMemberIds = metadata?.vcMemberIds ?? [] @@ -98,6 +102,7 @@ export async function collectLastFmCandidates( genreContext.sessionGenreFamilies ?? new Set() for (const seed of seedSlice) { + if (candidates.size >= AUTOPLAY_BUFFER_SIZE) break const lovedBoost = isLovedSeed(requestedBy.id, seed.artist, seed.title) ? LOVED_SEED_EXTRA_BOOST : 0 @@ -132,10 +137,9 @@ export async function collectLastFmCandidates( }) upsertScoredCandidate(candidates, track, { score: rec.score + LASTFM_SCORE_BOOST + lovedBoost, - reason: rec.reason - ? `${rec.reason} • last.fm taste${lovedBoost > 0 ? ' (loved)' : ''}` - : `last.fm taste${lovedBoost > 0 ? ' (loved)' : ''}`, - }) + source: 'lastfm-loved', + signals: rec.signals, + }, auditCollector) } const similar = await getSimilarTracks( @@ -178,10 +182,9 @@ export async function collectLastFmCandidates( }) upsertScoredCandidate(candidates, track, { score: (rec.score + LASTFM_SCORE_BOOST) * (s.match / 100), - reason: rec.reason - ? `${rec.reason} • similar to your taste` - : 'similar to your taste', - }) + source: 'lastfm-similar', + signals: rec.signals, + }, auditCollector) } if (candidates.size >= AUTOPLAY_BUFFER_SIZE) break } @@ -238,10 +241,9 @@ export async function collectLastFmCandidates( }) upsertScoredCandidate(candidates, track, { score: rec.score + LASTFM_SCORE_BOOST, - reason: rec.reason - ? `${rec.reason} • genre fallback` - : 'genre fallback', - }) + source: 'lastfm-genre-fallback', + signals: rec.signals, + }, auditCollector) } if (candidates.size >= 3) break } diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts index bc5a12cc3..4f6708fae 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts @@ -201,23 +201,24 @@ describe('getLastFmSeedSlice', () => { it('stops at pool length without wraparound within slice', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - ]) + // Pool of 20 so advancing by LASTFM_SEED_COUNT=15 leaves 5 items (indices 15–19) + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) await getLastFmSeedTracks('user-wrap') advanceLastFmSeedOffset('user-wrap') - const slice = getLastFmSeedSlice('user-wrap', 3) + const slice = getLastFmSeedSlice('user-wrap', 8) - expect(slice).toHaveLength(3) - expect(slice[0]).toEqual({ artist: 'A2', title: 'S2' }) - expect(slice[1]).toEqual({ artist: 'A3', title: 'S3' }) - expect(slice[2]).toEqual({ artist: 'A4', title: 'S4' }) + expect(slice).toHaveLength(5) + expect(slice[0]).toEqual({ artist: 'A16', title: 'S16' }) + expect(slice[4]).toEqual({ artist: 'A20', title: 'S20' }) }) it('returns at most min(count, tracks.length) items without wraparound', async () => { @@ -238,13 +239,13 @@ describe('getLastFmSeedSlice', () => { it('uses default LASTFM_SEED_COUNT when count not specified', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - { artist: 'A5', title: 'S5', playCount: 5 }, - ]) + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) await getLastFmSeedTracks('user-default-count') @@ -267,14 +268,14 @@ describe('advanceLastFmSeedOffset', () => { it('increments offset using modulo wrap-around', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - { artist: 'A5', title: 'S5', playCount: 5 }, - { artist: 'A6', title: 'S6', playCount: 6 }, - ]) + // Pool of 20 so (0+15)%20 = 15, no wrap + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) await getLastFmSeedTracks('user-advance') @@ -338,14 +339,14 @@ describe('getLastFmCacheOffset', () => { it('tracks offset progression through advances', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - { artist: 'A5', title: 'S5', playCount: 5 }, - { artist: 'A6', title: 'S6', playCount: 6 }, - ]) + // Pool of 20 so (0+15)%20 = 15, no wrap + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) await getLastFmSeedTracks('user-track-offset') @@ -433,13 +434,13 @@ describe('consumeLastFmSeedSlice', () => { it('uses default LASTFM_SEED_COUNT when count not specified', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - { artist: 'A5', title: 'S5', playCount: 5 }, - ]) + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) const slice = await consumeLastFmSeedSlice('user-default-consume') diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts index 810015116..25138a399 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts @@ -11,7 +11,7 @@ import { cleanTitle } from '../searchQueryCleaner' const CACHE_TTL_MS = 15 * 60 * 1000 const TOP_TRACKS_LIMIT = 50 const RECENT_TRACKS_LIMIT = 30 -export const LASTFM_SEED_COUNT = 5 +export const LASTFM_SEED_COUNT = 15 type CacheEntry = { tracks: { artist: string; title: string }[] diff --git a/packages/bot/src/utils/music/autoplay/pipeline.integration.spec.ts b/packages/bot/src/utils/music/autoplay/pipeline.integration.spec.ts new file mode 100644 index 000000000..f77b54ca1 --- /dev/null +++ b/packages/bot/src/utils/music/autoplay/pipeline.integration.spec.ts @@ -0,0 +1,252 @@ +/** + * Integration tests for the autoplay candidate-collection pipeline. + * + * Tests collectRecommendationCandidates end-to-end with real: + * - candidateScorer (calculateRecommendationScore, cross-locale veto) + * - candidateCollector helpers (shouldIncludeCandidate, upsertScoredCandidate) + * - diversitySelector helpers (isDuplicateCandidate) + * - languageHeuristics (detectSpanishMarkers) + * - searchQueryCleaner, trackNormalization + * + * Mocks only external I/O: + * - queue.player.search + * - Last.fm API + * - Spotify API + * - @lucky/shared/services + */ +import { describe, it, expect, jest, beforeEach } from '@jest/globals' +import type { Track, GuildQueue } from 'discord-player' + +jest.mock('@lucky/shared/utils', () => ({ + debugLog: jest.fn(), + errorLog: jest.fn(), + warnLog: jest.fn(), +})) + +jest.mock('@lucky/shared/services', () => ({ + spotifyLinkService: { getValidAccessToken: jest.fn().mockResolvedValue(null) }, + trackHistoryService: { addTrackToHistory: jest.fn().mockResolvedValue(true) }, + lastFmLinkService: { getLastFmLink: jest.fn().mockResolvedValue(null) }, +})) + +jest.mock('../../../lastfm', () => ({ + getArtistTopTags: jest.fn().mockResolvedValue([]), + getSimilarTracks: jest.fn().mockResolvedValue([]), + getTagTopTracks: jest.fn().mockResolvedValue([]), + getArtistTopTracks: jest.fn().mockResolvedValue([]), +})) + +jest.mock('../../../spotify/spotifyApi', () => ({ + getBatchAudioFeatures: jest.fn().mockResolvedValue(new Map()), + getArtistGenres: jest.fn().mockResolvedValue([]), + getArtistPopularity: jest.fn().mockResolvedValue(null), + searchSpotifyTrack: jest.fn().mockResolvedValue(null), +})) + +jest.mock('../../../spotify/spotifyUserSeeds', () => ({ + getUserSpotifySeeds: jest.fn().mockResolvedValue(null), +})) + +import { + collectRecommendationCandidates, + shouldIncludeCandidate, + upsertScoredCandidate, +} from './candidateCollector' +import type { SessionMood } from './sessionMood' +import { + getArtistTopTags, + getSimilarTracks, + getTagTopTracks, + getArtistTopTracks, +} from '../../../lastfm' +import { + getBatchAudioFeatures, + getArtistGenres, + getArtistPopularity, + searchSpotifyTrack, +} from '../../../spotify/spotifyApi' +import { getUserSpotifySeeds } from '../../../spotify/spotifyUserSeeds' + +function makeTrack(overrides: Partial = {}): Track { + return { + title: 'Test Track', + author: 'Test Artist', + durationMS: 3 * 60 * 1000, + url: 'https://open.spotify.com/track/abc123', + id: 'abc123', + source: 'spotify', + thumbnail: null, + description: '', + views: 0, + requestedBy: null, + raw: {}, + metadata: null, + ...overrides, + } as unknown as Track +} + +function makeQueue(currentTrack?: Track): GuildQueue { + const searchMock = jest.fn() + return { + guild: { id: 'guild-integration' }, + tracks: { toArray: () => [], size: 0, values: () => [].values() }, + currentTrack: currentTrack ?? makeTrack(), + history: { tracks: { toArray: () => [] } }, + metadata: {}, + player: { search: searchMock }, + } as unknown as GuildQueue +} + +describe('autoplay pipeline integration', () => { + beforeEach(() => { + jest.clearAllMocks() + // resetMocks: true wipes mockResolvedValue — re-set each test + ;(getArtistTopTags as jest.Mock).mockResolvedValue([]) + ;(getSimilarTracks as jest.Mock).mockResolvedValue([]) + ;(getTagTopTracks as jest.Mock).mockResolvedValue([]) + ;(getArtistTopTracks as jest.Mock).mockResolvedValue([]) + ;(getBatchAudioFeatures as jest.Mock).mockResolvedValue(new Map()) + ;(getArtistGenres as jest.Mock).mockResolvedValue([]) + ;(getArtistPopularity as jest.Mock).mockResolvedValue(null) + ;(searchSpotifyTrack as jest.Mock).mockResolvedValue(null) + ;(getUserSpotifySeeds as jest.Mock).mockResolvedValue(null) + }) + + describe('cross-locale veto via real candidateScorer', () => { + it('drops Spanish gospel tracks when session has no Spanish history', async () => { + const spanishGospelTracks: Track[] = [ + makeTrack({ title: 'Aleluya a Tu Gloria', author: 'Marco Barrientos', url: 'https://open.spotify.com/track/sg1', id: 'sg1' }), + makeTrack({ title: 'Dios de lo Imposible', author: 'Elevation Worship Español', url: 'https://open.spotify.com/track/sg2', id: 'sg2' }), + makeTrack({ title: 'Bendición', author: 'Redimi2', url: 'https://open.spotify.com/track/sg3', id: 'sg3' }), + ] + + const currentTrack = makeTrack({ title: 'Creep', author: 'Radiohead', url: 'https://open.spotify.com/track/rh1', id: 'rh1' }) + const queue = makeQueue(currentTrack) + + // All search calls return Spanish gospel tracks + ;(queue.player.search as jest.Mock).mockResolvedValue({ tracks: spanishGospelTracks, playlist: null }) + + // dominantLocale: null → English session → veto fires on Spanish content + const englishSession: SessionMood = { + deepDiveArtist: null, + preferLong: false, + preferShort: false, + restless: false, + dominantLocale: null, + } + + const candidates = await collectRecommendationCandidates( + queue, + [currentTrack], + null, // no requestedBy → skips Spotify recommendations path + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + currentTrack, + new Set(['radiohead']), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + englishSession, + ) + + // candidateScorer returns -Infinity for Spanish gospel when no Spanish session history. + // upsertScoredCandidate drops non-finite scores, so the map must stay empty. + expect(candidates.size).toBe(0) + }) + + it('accepts clearly non-Spanish tracks when search returns them', async () => { + const englishTracks: Track[] = [ + makeTrack({ title: 'Karma Police', author: 'Radiohead', url: 'https://open.spotify.com/track/rh2', id: 'rh2' }), + makeTrack({ title: 'No Surprises', author: 'Radiohead', url: 'https://open.spotify.com/track/rh3', id: 'rh3' }), + ] + + const currentTrack = makeTrack({ title: 'Creep', author: 'Radiohead', url: 'https://open.spotify.com/track/rh1', id: 'rh1' }) + const queue = makeQueue(currentTrack) + + ;(queue.player.search as jest.Mock).mockResolvedValue({ tracks: englishTracks, playlist: null }) + + const englishSession: SessionMood = { + deepDiveArtist: null, + preferLong: false, + preferShort: false, + restless: false, + dominantLocale: null, + } + + const candidates = await collectRecommendationCandidates( + queue, + [currentTrack], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + currentTrack, + new Set(['radiohead']), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + englishSession, + ) + + // English tracks pass the locale veto — at least one should be accepted + expect(candidates.size).toBeGreaterThan(0) + }) + }) + + describe('shouldIncludeCandidate (pure unit integration with real diversitySelector)', () => { + it('includes tracks not in either excluded set', () => { + const track = makeTrack({ url: 'https://open.spotify.com/track/new1', title: 'New Song', author: 'Artist A' }) + expect(shouldIncludeCandidate(track, new Set(), new Set())).toBe(true) + }) + + it('excludes tracks whose URL is in excludedUrls', () => { + const url = 'https://open.spotify.com/track/played1' + const track = makeTrack({ url }) + expect(shouldIncludeCandidate(track, new Set([url]), new Set())).toBe(false) + }) + }) + + describe('upsertScoredCandidate (pure unit integration with real logic)', () => { + it('drops -Infinity scores — the cross-locale veto gate', () => { + const candidates = new Map() + const track = makeTrack({ title: 'Aleluya', author: 'Marco Barrientos' }) + upsertScoredCandidate(candidates, track, { score: -Infinity, source: 'spotify-rec', signals: [] }) + expect(candidates.size).toBe(0) + }) + + it('drops NaN scores defensively', () => { + const candidates = new Map() + const track = makeTrack() + upsertScoredCandidate(candidates, track, { score: NaN, source: 'spotify-rec', signals: [] }) + expect(candidates.size).toBe(0) + }) + + it('keeps higher score when same-key track inserted twice', () => { + const candidates = new Map() + const track = makeTrack({ title: 'Rock Song', author: 'Radiohead', url: 'https://open.spotify.com/track/rh1' }) + upsertScoredCandidate(candidates, track, { score: 0.5, source: 'spotify-rec', signals: [] }) + upsertScoredCandidate(candidates, track, { score: 0.8, source: 'spotify-rec', signals: ['preferred artist'] }) + expect(candidates.size).toBe(1) + expect([...candidates.values()][0]!.score).toBe(0.8) + }) + + it('retains first score when second insert is lower', () => { + const candidates = new Map() + const track = makeTrack({ title: 'Rock Song', author: 'Radiohead', url: 'https://open.spotify.com/track/rh2' }) + upsertScoredCandidate(candidates, track, { score: 0.9, source: 'spotify-rec', signals: [] }) + upsertScoredCandidate(candidates, track, { score: 0.3, source: 'spotify-rec', signals: [] }) + expect([...candidates.values()][0]!.score).toBe(0.9) + }) + }) +}) diff --git a/packages/bot/src/utils/music/autoplay/recommendationBasis.ts b/packages/bot/src/utils/music/autoplay/recommendationBasis.ts new file mode 100644 index 000000000..0e5cbfb70 --- /dev/null +++ b/packages/bot/src/utils/music/autoplay/recommendationBasis.ts @@ -0,0 +1,82 @@ +/** + * Recommendation basis types for autoplay track selection. + * Tracks the source and signals that led to a candidate track being scored. + */ + +export type RecommendationSource = + | 'spotify-rec' + | 'spotify-taste' + | 'lastfm-loved' + | 'lastfm-similar' + | 'lastfm-genre-fallback' + | 'artist-fallback' + | 'genre-tag' + +export type RecommendationSignal = + | 'preferred artist' + | 'favourite artist' + | 'liked artist' + | 'known artist' + | 'liked track' + | 'old dislike' + | 'skipped before' + | 'completed before' + | 'album match' + | 'deep-dive artist' + | 'session novelty' + | 'source variety' + | 'similar title mood' + | 'similar energy' + | 'long track penalty' + | 'deep dive' + | 'long track match' + | 'quick hit match' + | 'restless discovery' + | 'spotify preferred' + | 'genre family drift' + | 'version variant' + | 'low quality upload' + | 'discovery boost' + | 'energy match' + +export interface RecommendationBasis { + source: RecommendationSource + signals: RecommendationSignal[] +} + +/** + * Formats a recommendation basis as a human-friendly label for Discord display. + * Produces clean output without duplicates, mapping the source to a readable label first, + * then appending significant signals joined with " • ". + * + * Example output: "spotify rec • preferred artist • completed before" + * + * @param basis - The recommendation basis containing source and signals + * @returns Formatted string suitable for Discord display + */ +export function serializeBasis(basis: RecommendationBasis): string { + // Map sources to human-readable labels + const sourceLabels: Record = { + 'spotify-rec': 'spotify rec', + 'spotify-taste': 'spotify taste', + 'lastfm-loved': 'last.fm loved', + 'lastfm-similar': 'last.fm similar', + 'lastfm-genre-fallback': 'last.fm genre', + 'artist-fallback': 'artist fallback', + 'genre-tag': 'genre tag', + } + + const sourceLabel = sourceLabels[basis.source] + + // Remove duplicate signals while preserving order + const uniqueSignals = Array.from(new Set(basis.signals)) + + // Combine source with all unique signals + if (uniqueSignals.length === 0) { + return sourceLabel + } + + // Join signals with " • " separator + const signalsFormatted = uniqueSignals.join(' • ') + return `${sourceLabel} • ${signalsFormatted}` +} diff --git a/packages/bot/src/utils/music/autoplay/recommendations.spec.ts b/packages/bot/src/utils/music/autoplay/recommendations.spec.ts deleted file mode 100644 index 7f9edafcc..000000000 --- a/packages/bot/src/utils/music/autoplay/recommendations.spec.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { - describe, - test, - expect, - beforeEach, - jest, - afterEach, -} from '@jest/globals' -import type { Track } from 'discord-player' - -const debugLogMock = jest.fn() -const errorLogMock = jest.fn() - -jest.mock('@lucky/shared/utils', () => ({ - debugLog: (...args: unknown[]) => debugLogMock(...args), - errorLog: (...args: unknown[]) => errorLogMock(...args), -})) - -jest.mock('@lucky/shared/services', () => ({ - trackHistoryService: { - getTrackHistory: jest.fn(), - }, - guildSettingsService: {}, -})) - -import { - getAutoplayRecommendations, - updateRecommendationConfig, - getRecommendationConfig, -} from './recommendations' - -describe('autoplay/recommendations', () => { - let mockGetTrackHistory: jest.Mock - - beforeEach(() => { - jest.clearAllMocks() - const { trackHistoryService } = require('@lucky/shared/services') - mockGetTrackHistory = trackHistoryService.getTrackHistory as jest.Mock - - // Reset recommendation config to defaults - updateRecommendationConfig({ - maxRecommendations: 8, - similarityThreshold: 0.4, - genreWeight: 0.4, - tagWeight: 0.3, - artistWeight: 0.2, - durationWeight: 0.05, - popularityWeight: 0.05, - diversityFactor: 0.1, - }) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - const mockTrack = (id: string): Track => - ({ - id, - title: `Track ${id}`, - author: 'Artist A', - duration: 180000, - url: `https://example.com/${id}`, - thumbnail: '', - description: 'Test track', - views: 1000, - requestedBy: null, - source: 'youtube', - raw: {} as Record, - metadata: {}, - }) as unknown as Track - - const mockRecommendationResult = (id: string) => ({ - track: mockTrack(id), - score: 0.8, - reasons: ['Similar style'], - }) - - describe('getAutoplayRecommendations', () => { - test('returns empty array when no current track and no history', async () => { - mockGetTrackHistory.mockResolvedValue([]) - - const result = await getAutoplayRecommendations('guild-1') - - expect(result).toEqual([]) - }) - - test('uses history for recommendations when no current track', async () => { - const historyEntry = { - url: 'https://example.com/history-1', - timestamp: Date.now(), - trackId: 'history-1', - } - mockGetTrackHistory.mockResolvedValue([historyEntry]) - - const result = await getAutoplayRecommendations('guild-1') - - expect(result).toEqual([]) - }) - - test('respects default limit of 5', async () => { - const currentTrack = mockTrack('current') - mockGetTrackHistory.mockResolvedValue([]) - - await getAutoplayRecommendations('guild-1', currentTrack) - - expect(mockGetTrackHistory).toHaveBeenCalledWith('guild-1', 10) - }) - - test('handles error when track history fetch fails', async () => { - mockGetTrackHistory.mockRejectedValue(new Error('DB error')) - - const result = await getAutoplayRecommendations('guild-1') - - expect(result).toEqual([]) - expect(errorLogMock).toHaveBeenCalled() - }) - - test('logs debug info when getting recommendations', async () => { - const currentTrack = mockTrack('current') - mockGetTrackHistory.mockResolvedValue([]) - - await getAutoplayRecommendations('guild-1', currentTrack, 5) - - expect(debugLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Getting autoplay recommendations', - data: expect.objectContaining({ - guildId: 'guild-1', - hasCurrentTrack: true, - limit: 5, - }), - }), - ) - }) - - test('logs debug when no current track', async () => { - mockGetTrackHistory.mockResolvedValue([]) - - await getAutoplayRecommendations('guild-1') - - expect(debugLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Getting autoplay recommendations', - data: expect.objectContaining({ - guildId: 'guild-1', - hasCurrentTrack: false, - }), - }), - ) - }) - - test('fetches 10 items from history', async () => { - mockGetTrackHistory.mockResolvedValue([]) - - await getAutoplayRecommendations('guild-1') - - expect(mockGetTrackHistory).toHaveBeenCalledWith('guild-1', 10) - }) - - test('returns empty when available tracks are empty', async () => { - const currentTrack = mockTrack('current') - mockGetTrackHistory.mockResolvedValue([]) - - const result = await getAutoplayRecommendations( - 'guild-1', - currentTrack, - ) - - expect(result).toEqual([]) - }) - - test('logs count of generated recommendations as 0', async () => { - const currentTrack = mockTrack('current') - mockGetTrackHistory.mockResolvedValue([]) - - await getAutoplayRecommendations('guild-1', currentTrack) - - expect(debugLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Autoplay recommendations generated', - data: expect.objectContaining({ - guildId: 'guild-1', - count: 0, - }), - }), - ) - }) - - test('logs error when recommendation service fails', async () => { - const currentTrack = mockTrack('current') - mockGetTrackHistory.mockResolvedValue([]) - - try { - await getAutoplayRecommendations('guild-1', currentTrack) - } catch { - expect(errorLogMock).toHaveBeenCalled() - } - }) - }) - - describe('updateRecommendationConfig', () => { - test('logs the updated configuration', () => { - const newConfig = { diversityFactor: 0.5 } - - updateRecommendationConfig(newConfig) - - expect(debugLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Updated recommendation configuration', - data: newConfig, - }), - ) - }) - - test('logs with multiple config properties', () => { - const newConfig = { - maxRecommendations: 15, - similarityThreshold: 0.6, - } - - updateRecommendationConfig(newConfig) - - expect(debugLogMock).toHaveBeenCalled() - }) - }) - - describe('getRecommendationConfig', () => { - test('returns current configuration', () => { - const config = getRecommendationConfig() - - expect(config).toBeDefined() - expect(config).toHaveProperty('maxRecommendations') - expect(config).toHaveProperty('similarityThreshold') - }) - - test('returns configuration with all expected properties', () => { - const config = getRecommendationConfig() - - expect(config.maxRecommendations).toBe(8) - expect(config.similarityThreshold).toBe(0.4) - expect(config.genreWeight).toBe(0.4) - expect(config.tagWeight).toBe(0.3) - expect(config.artistWeight).toBe(0.2) - }) - - test('includes diversity factor', () => { - const config = getRecommendationConfig() - - expect(config.diversityFactor).toBe(0.1) - }) - - test('includes artist and source constraints', () => { - const config = getRecommendationConfig() - - expect(config).toHaveProperty('maxTracksPerArtist') - expect(config).toHaveProperty('maxTracksPerSource') - }) - }) -}) diff --git a/packages/bot/src/utils/music/autoplay/replenisher.spec.ts b/packages/bot/src/utils/music/autoplay/replenisher.spec.ts index 03503e74b..58005a67f 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.spec.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.spec.ts @@ -17,6 +17,7 @@ jest.mock('@lucky/shared/services', () => ({ }, spotifyLinkService: { getValidAccessToken: jest.fn(), + getByDiscordId: jest.fn(), }, premiumService: { isPremium: jest.fn(), @@ -80,10 +81,16 @@ function createTrack(overrides: Partial = {}): Track { } as Track } +function createTracksMap(entries: [string, Track][] = []): Map & { toArray: () => Track[] } { + const map = new Map(entries) as Map & { toArray: () => Track[] } + map.toArray = () => [...map.values()] + return map +} + function createGuildQueue(overrides: Partial = {}): GuildQueue { return { guild: { id: 'guildid' }, - tracks: new Map(), + tracks: createTracksMap(), currentTrack: createTrack(), metadata: {}, history: { tracks: { toArray: () => [] } }, @@ -193,11 +200,12 @@ describe('replenishQueue', () => { it('should skip replenish if queue is full (>= AUTOPLAY_BUFFER_SIZE)', async () => { const queue = createGuildQueue() - const tracks = new Map() + const entries: [string, Track][] = [] for (let i = 0; i < 10; i++) { - tracks.set(`track${i}`, createTrack({ id: `track${i}` })) + const track = createTrack({ id: `track${i}`, metadata: { isAutoplay: true } as Record }) + entries.push([`track${i}`, track]) } - queue.tracks = tracks + queue.tracks = createTracksMap(entries) const { collectRecommendationCandidates } = require('./candidateCollector') @@ -209,6 +217,25 @@ describe('replenishQueue', () => { ).not.toHaveBeenCalled() }) + it('should replenish when queue has user-added tracks but autoplay count is below buffer', async () => { + const queue = createGuildQueue() + const entries: [string, Track][] = [] + for (let i = 0; i < 8; i++) { + const track = createTrack({ id: `user${i}`, metadata: undefined }) + entries.push([`user${i}`, track]) + } + const autoTrack = createTrack({ id: 'auto0', metadata: { isAutoplay: true } as Record }) + entries.push(['auto0', autoTrack]) + queue.tracks = createTracksMap(entries) + + const { collectRecommendationCandidates } = + require('./candidateCollector') + + await replenishQueue(queue) + + expect(collectRecommendationCandidates).toHaveBeenCalled() + }) + it('should handle errors gracefully without throwing', async () => { const queue = createGuildQueue() const { collectRecommendationCandidates } = @@ -260,8 +287,8 @@ describe('replenishQueue', () => { require('../queueManipulation') const mockScoredTracks = [ - { track: createTrack({ id: 'track1' }), score: 0.8, reason: 'test' }, - { track: createTrack({ id: 'track2' }), score: 0.7, reason: 'test' }, + { track: createTrack({ id: 'track1' }), score: 0.8, basis: { source: 'spotify-rec', signals: [] } }, + { track: createTrack({ id: 'track2' }), score: 0.7, basis: { source: 'spotify-rec', signals: [] } }, ] selectDiverseCandidates.mockReturnValue(mockScoredTracks) interleaveByArtist.mockReturnValue(mockScoredTracks) @@ -270,7 +297,7 @@ describe('replenishQueue', () => { const candidateMap = new Map() candidateMap.set('candidate1', { track: createTrack({ id: 'candidate1' }), - reason: 'test', + basis: { source: 'spotify-rec', signals: [] }, score: 0.5, }) collectRecommendationCandidates.mockResolvedValue(candidateMap) @@ -296,4 +323,20 @@ describe('replenishQueue', () => { }), ) }) + + it('passes Spotify genre fallback to createArtistTagFetcher when token is available', async () => { + const queue = createGuildQueue({ + currentTrack: createTrack({ requestedBy: { id: 'user-123' } as import('discord.js').User }), + }) + const { spotifyLinkService } = require('@lucky/shared/services') + spotifyLinkService.getValidAccessToken.mockResolvedValue('test-token') + + const { createArtistTagFetcher } = require('./artistTagCache') + + await replenishQueue(queue) + + expect(createArtistTagFetcher).toHaveBeenCalledWith( + expect.any(Function), + ) + }) }) diff --git a/packages/bot/src/utils/music/autoplay/replenisher.ts b/packages/bot/src/utils/music/autoplay/replenisher.ts index 5720b5a7d..503ea802b 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.ts @@ -10,13 +10,17 @@ import { } from '@lucky/shared/services' import { getArtistPopularity, + getArtistGenres, } from '../../../spotify/spotifyApi' import { detectSessionMood, type SessionMood } from './sessionMood' +import { getRecentSkipCount } from '../../../handlers/player/trackHandlers' import { collectRecommendationCandidates, + SERTANEJO_TAGS, } from './candidateCollector' import { createArtistTagFetcher, + hasGenreTag, type ArtistTagFetcher, } from './artistTagCache' import { getGenreFamilies } from './candidateScorer' @@ -28,6 +32,7 @@ import { purgeDuplicatesOfCurrentTrack, } from './diversitySelector' import { collectLastFmCandidates } from './lastFmSeeder' +import { serializeBasis } from './recommendationBasis' import { cleanAuthor } from '../searchQueryCleaner' import type { QueueMetadata } from '../../../types/QueueMetadata' import { @@ -100,7 +105,10 @@ async function _replenishQueue( const bufferSize = (await premiumService.isPremium(queue.guild.id)) ? AUTOPLAY_BUFFER_SIZE_PREMIUM : AUTOPLAY_BUFFER_SIZE - const missingTracks = bufferSize - queue.tracks.size + const autoplayInQueue = [...queue.tracks.toArray()].filter( + (t) => (t.metadata as { isAutoplay?: boolean } | undefined)?.isAutoplay === true, + ).length + const missingTracks = bufferSize - autoplayInQueue if (missingTracks <= 0) return const allHistoryTracks = getAllHistoryTracks(queue) @@ -111,7 +119,7 @@ async function _replenishQueue( Math.abs(allHistoryTracks.length - guildMoodCache.historyLen) < 3 ? guildMoodCache.mood : (() => { - const mood = detectSessionMood(allHistoryTracks, 0 /* TODO: wire recentSkipCount from skip tracking */) + const mood = detectSessionMood(allHistoryTracks, getRecentSkipCount(replenishGuildId)) sessionMoodCache.set(replenishGuildId, { mood, historyLen: allHistoryTracks.length, @@ -223,16 +231,33 @@ async function _replenishQueue( : null const replenishCount = replenishCounters.get(guildId) ?? 0 - // Tag-driven genre context (Phase 2). One Last.fm artist-tag cache for - // the whole pass — every candidate collector reuses it, plus the - // current track + recent history. Falls through to no-op when Last.fm - // is not configured. - const getArtistTags: ArtistTagFetcher = createArtistTagFetcher() + // Tag-driven genre context (Phase 2). One artist-tag cache for the + // whole pass — every candidate collector reuses it. When Last.fm is not + // linked the fetcher falls back to Spotify genre strings so the + // cross-locale veto can still reject Spanish gospel tracks whose + // title/author carry no Spanish text markers. + const spotifyToken = requestedBy?.id + ? await Promise.resolve( + spotifyLinkService.getValidAccessToken(requestedBy.id), + ).catch(() => null) + : null + const getArtistTags: ArtistTagFetcher = createArtistTagFetcher( + spotifyToken + ? (artist) => getArtistGenres(spotifyToken, artist) + : undefined, + ) const currentTrackTags = await getArtistTags(currentTrack.author) const sessionGenreFamilies = await detectSessionGenreFamilies( historyTracks, getArtistTags, ) + const seedIsSertanejo = currentTrackTags.length > 0 + ? hasGenreTag(currentTrackTags, SERTANEJO_TAGS) + : false + // Block sertanejo candidates unless the seed itself is sertanejo — fail-open + // when tags are absent (Last.fm unlinked) to avoid over-filtering. + const blockSertanejo = !seedIsSertanejo + const candidateGenreContext = { getArtistTags, currentTrackTags, @@ -244,6 +269,7 @@ async function _replenishQueue( guildId, currentTrackTagCount: currentTrackTags.length, sessionGenreFamilies: Array.from(sessionGenreFamilies), + blockSertanejo, }, }) const candidates = await collectRecommendationCandidates( @@ -266,6 +292,7 @@ async function _replenishQueue( sessionMood, currentFeatures, candidateGenreContext, + blockSertanejo, ) sourcesCounts.recommendation = candidates.size candidatePoolSize = candidates.size @@ -328,6 +355,10 @@ async function _replenishQueue( implicitDislikeKeys, implicitLikeKeys, sessionMood, + genreContext: { + currentTrackTags, + sessionGenreFamilies, + }, }, ) sourcesCounts.genre = candidates.size - beforeGenre @@ -361,6 +392,7 @@ async function _replenishQueue( implicitDislikeKeys, implicitLikeKeys, sessionMood, + candidateGenreContext, ) sourcesCounts.fallback = candidates.size - beforeFallback debugLog({ @@ -466,7 +498,7 @@ async function _replenishQueue( title: s.track.title, author: s.track.author, score: s.score.toFixed(3), - reason: s.reason, + reason: serializeBasis(s.basis), url: s.track.url, })), }, diff --git a/packages/bot/src/utils/music/autoplay/spotifyRecommender.spec.ts b/packages/bot/src/utils/music/autoplay/spotifyRecommender.spec.ts index 99692b3b7..022798a0f 100644 --- a/packages/bot/src/utils/music/autoplay/spotifyRecommender.spec.ts +++ b/packages/bot/src/utils/music/autoplay/spotifyRecommender.spec.ts @@ -17,6 +17,7 @@ jest.mock('@lucky/shared/utils', () => ({ const spotifyLinkServiceMock = jest.fn() const searchSpotifyTrackMock = jest.fn() const getSpotifyRecommendationsMock = jest.fn() +const getArtistGenresMock = jest.fn() jest.mock('@lucky/shared/services', () => ({ spotifyLinkService: { @@ -30,6 +31,8 @@ jest.mock('../../../spotify/spotifyApi', () => ({ searchSpotifyTrackMock(...args), getSpotifyRecommendations: (...args: unknown[]) => getSpotifyRecommendationsMock(...args), + getArtistGenres: (...args: unknown[]) => + getArtistGenresMock(...args), })) jest.mock('../searchQueryCleaner', () => ({ @@ -65,6 +68,7 @@ describe('spotifyRecommender', () => { spotifyLinkServiceMock.mockResolvedValue('test-token') searchSpotifyTrackMock.mockResolvedValue('spotify-id') getSpotifyRecommendationsMock.mockResolvedValue([]) + getArtistGenresMock.mockResolvedValue([]) }) describe('collectSpotifyRecommendationCandidates', () => { @@ -302,94 +306,148 @@ describe('spotifyRecommender', () => { }, ) }) - }) - describe('searchSeedCandidates', () => { - it('returns empty array on failure', async () => { + it('falls back to Spotify genres when Last.fm tags are empty and rejects Spanish gospel', async () => { + // Simulates the case where Last.fm is not linked: artistTagCache returns [] + // and Spotify genres identify the artist as latin gospel → cross-locale veto fires + getSpotifyRecommendationsMock.mockResolvedValue([{ id: 'rec-gospel' }] as any) + // getArtistTags returns [] (no Last.fm) + // getArtistGenres returns Spotify genre tags that reveal Spanish content + getArtistGenresMock.mockResolvedValue(['musica cristiana', 'latin gospel']) + const queueMock = createMockQueue() - ;(queueMock.player.search as jest.Mock).mockRejectedValue( - new Error('Search failed'), - ) + ;(queueMock.player.search as jest.Mock).mockResolvedValue({ + tracks: [createTrack({ title: 'Hosanna', author: 'Marcos Witt' })], + }) + const candidates = new Map() const user = { id: 'user123' } as any + const sessionMood = { + deepDiveArtist: null, + preferLong: false, + preferShort: false, + restless: false, + dominantLocale: null, + recentSkipCount: 0, + } - const result = await searchSeedCandidates( + await collectSpotifyRecommendationCandidates( queueMock, - createTrack(), + [createTrack({ url: 'https://open.spotify.com/track/seed1' })], user, - 0, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + sessionMood, + null, ) - expect(result).toEqual([]) + // Spanish gospel track with neutral English title must be rejected + expect(candidates.size).toBe(0) + expect(getArtistGenresMock).toHaveBeenCalledWith('test-token', 'Marcos Witt') }) - it('returns tracks from first successful engine', async () => { + it('uses Spotify genres only when Last.fm tags are absent (does not double-fetch)', async () => { + getSpotifyRecommendationsMock.mockResolvedValue([{ id: 'rec-1' }] as any) + // When Last.fm DOES return tags, getArtistGenres should NOT be called + const mockGetArtistTags = jest.fn().mockResolvedValue(['pop', 'indie']) + getArtistGenresMock.mockResolvedValue(['pop']) + const queueMock = createMockQueue() - const tracks = [createTrack({ title: 'Result 1' })] ;(queueMock.player.search as jest.Mock).mockResolvedValue({ - tracks, + tracks: [createTrack()], }) + const candidates = new Map() const user = { id: 'user123' } as any - const result = await searchSeedCandidates( + await collectSpotifyRecommendationCandidates( queueMock, - createTrack(), + [createTrack({ url: 'https://open.spotify.com/track/seed1' })], user, - 0, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + { getArtistTags: mockGetArtistTags }, ) - expect(result).toEqual(tracks) - expect(queueMock.player.search).toHaveBeenCalledWith( - expect.any(String), - { - requestedBy: user, - searchEngine: QueryType.SPOTIFY_SEARCH, - }, - ) + // Last.fm tags were returned, so getArtistGenres should not be called + expect(getArtistGenresMock).not.toHaveBeenCalled() }) + }) - it('tries fallback engines on failure', async () => { + describe('searchSeedCandidates', () => { + it('returns empty array on search failure', async () => { const queueMock = createMockQueue() - ;(queueMock.player.search as jest.Mock) - .mockResolvedValueOnce({ tracks: [] }) - .mockResolvedValueOnce({ - tracks: [createTrack({ title: 'YouTube result' })], - }) + ;(queueMock.player.search as jest.Mock).mockRejectedValue( + new Error('Search failed'), + ) - const user = { id: 'user123' } as any + const result = await searchSeedCandidates( + queueMock, + createTrack(), + { id: 'user123' } as any, + ) + + expect(result).toEqual([]) + }) + + it('returns empty array when Spotify returns 0 results — no YouTube fallback', async () => { + const queueMock = createMockQueue() + ;(queueMock.player.search as jest.Mock).mockResolvedValue({ + tracks: [], + }) const result = await searchSeedCandidates( queueMock, createTrack(), - user, - 0, + { id: 'user123' } as any, ) - expect(result.length).toBeGreaterThan(0) - expect(queueMock.player.search).toHaveBeenCalledTimes(2) + expect(result).toEqual([]) + expect(queueMock.player.search).toHaveBeenCalledTimes(1) + expect(queueMock.player.search).toHaveBeenCalledWith( + expect.any(String), + { requestedBy: expect.anything(), searchEngine: QueryType.SPOTIFY_SEARCH }, + ) }) - it('applies query modifiers based on replenishCount for non-Spotify', async () => { + it('uses only Spotify search engine', async () => { const queueMock = createMockQueue() - ;(queueMock.player.search as jest.Mock) - .mockResolvedValueOnce({ tracks: [] }) - .mockResolvedValueOnce({ - tracks: [createTrack()], - }) + const tracks = [createTrack({ title: 'Result 1' })] + ;(queueMock.player.search as jest.Mock).mockResolvedValue({ tracks }) const user = { id: 'user123' } as any + const result = await searchSeedCandidates(queueMock, createTrack(), user) - await searchSeedCandidates( - queueMock, - createTrack({ title: 'Test', author: 'Artist' }), - user, - 1, + expect(result).toEqual(tracks) + expect(queueMock.player.search).toHaveBeenCalledTimes(1) + expect(queueMock.player.search).toHaveBeenCalledWith( + expect.any(String), + { requestedBy: user, searchEngine: QueryType.SPOTIFY_SEARCH }, ) - - const youtubeCall = (queueMock.player.search as jest.Mock).mock - .calls[1][0] - expect(youtubeCall).toContain('similar') }) it('filters tracks exceeding max duration', async () => { @@ -402,13 +460,10 @@ describe('spotifyRecommender', () => { ], }) - const user = { id: 'user123' } as any - const result = await searchSeedCandidates( queueMock, createTrack(), - user, - 0, + { id: 'user123' } as any, ) expect(result.length).toBeLessThanOrEqual(2) @@ -428,41 +483,13 @@ describe('spotifyRecommender', () => { tracks: manyTracks, }) - const user = { id: 'user123' } as any - const result = await searchSeedCandidates( queueMock, createTrack(), - user, - 0, + { id: 'user123' } as any, ) expect(result.length).toBeLessThanOrEqual(8) }) - - it('uses different search query for non-Spotify engines', async () => { - const queueMock = createMockQueue() - ;(queueMock.player.search as jest.Mock) - .mockResolvedValueOnce({ tracks: [] }) - .mockResolvedValueOnce({ - tracks: [createTrack({ source: 'youtube' })], - }) - - const user = { id: 'user123' } as any - - await searchSeedCandidates( - queueMock, - createTrack(), - user, - 0, - ) - - const calls = (queueMock.player.search as jest.Mock).mock.calls - const spotifyCall = calls[0][0] - const youtubeCall = calls[1][0] - - expect(typeof spotifyCall).toBe('string') - expect(typeof youtubeCall).toBe('string') - }) }) }) diff --git a/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts b/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts index 6adc89168..cb6765f3a 100644 --- a/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts +++ b/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts @@ -4,11 +4,13 @@ import { type GuildQueue, } from 'discord-player' import type { User } from 'discord.js' -import { debugLog, warnLog } from '@lucky/shared/utils' +import { debugLog } from '@lucky/shared/utils' +import { logAndSwallow } from '@lucky/shared/utils/error' import { spotifyLinkService } from '@lucky/shared/services' import { searchSpotifyTrack, getSpotifyRecommendations, + getArtistGenres, type SpotifyAudioFeatures, } from '../../../spotify/spotifyApi' import { getUserSpotifySeeds } from '../../../spotify/spotifyUserSeeds' @@ -28,19 +30,11 @@ import { } from '../queueManipulation' import { calculateRecommendationScore } from './candidateScorer' import { createArtistTagFetcher, type ArtistTagFetcher } from './artistTagCache' -import type { ScoredTrack } from './diversitySelector'; +import type { ScoredTrack } from './diversitySelector' +import type { AutoplayAuditCollector } from './autoplayAudit' const MAX_AUTOPLAY_DURATION_MS = 7 * 60 * 1000 const SEARCH_RESULTS_LIMIT = 8 -const BASE_QUERY_MODIFIERS = ['', 'similar', 'like', 'playlist', 'mix'] -const FULL_QUERY_MODIFIERS = [ - ...BASE_QUERY_MODIFIERS, - 'acoustic', - 'live', - 'electric', - 'band version', - 'session', -] export async function collectSpotifyRecommendationCandidates( queue: GuildQueue, @@ -66,6 +60,7 @@ export async function collectSpotifyRecommendationCandidates( currentTrackTags?: string[] sessionGenreFamilies?: Set } = {}, + auditCollector?: AutoplayAuditCollector, ): Promise { const getArtistTags = genreContext.getArtistTags ?? createArtistTagFetcher() const currentTrackTags = genreContext.currentTrackTags ?? [] @@ -81,9 +76,13 @@ export async function collectSpotifyRecommendationCandidates( getUserSpotifySeeds(requestedBy.id), ).catch(() => null) - const seedIds = seedTracks + const likedSeedIds = userSpotifySeeds?.likedTrackIds?.slice(0, 3) ?? [] + const queueSeedIds = seedTracks .map(extractSpotifyTrackId) .filter((id): id is string => id !== null) + // Liked tracks take priority; fill remaining slots from queue history (max 5 total per Spotify API limit) + const seedIds = [...likedSeedIds, ...queueSeedIds] + .filter((id, i, arr) => arr.indexOf(id) === i) .slice(0, 5) if (seedIds.length === 0) { @@ -143,7 +142,16 @@ export async function collectSpotifyRecommendationCandidates( const normalizedKey = normalizeTrackKey(track.title, track.author) const dislikedWeight = dislikedWeights.get(normalizedKey) if (dislikedWeight !== undefined && dislikedWeight > 0.5) continue - const tags = await getArtistTags(track.author) + const lastFmTags = await getArtistTags(track.author) + // When Last.fm is not linked, fall back to Spotify genres so the + // cross-locale veto can still catch Spanish gospel tracks whose + // title/artist name has no Spanish text markers. + const tags = lastFmTags.length > 0 + ? lastFmTags + : await getArtistGenres(token, track.author).catch((err) => { + logAndSwallow(err, 'spotifyRecommender:getArtistGenres', { trackAuthor: track.author }) + return [] as string[] + }) const rec = calculateRecommendationScore({ candidate: track, currentTrack, @@ -164,20 +172,22 @@ export async function collectSpotifyRecommendationCandidates( }, }) let score = rec.score + 0.3 - let reason = rec.reason ? `${rec.reason} • spotify rec` : 'spotify rec' + let source: 'spotify-rec' | 'spotify-taste' = 'spotify-rec' + let signals = rec.signals if (userSpotifySeeds !== null) { const trackArtistLower = track.author.toLowerCase() if (userSpotifySeeds.artistNames.has(trackArtistLower)) { score += 0.08 - reason += ' • spotify taste' + source = 'spotify-taste' } } upsertScoredCandidate(candidates, track, { score, - reason, - }) + source, + signals, + }, auditCollector) } } @@ -185,15 +195,8 @@ export async function searchSeedCandidates( queue: GuildQueue, seed: Track, requestedBy: User | null, - replenishCount = 0, - sessionMood?: import('./sessionMood').SessionMood | null, ): Promise { - const modifiers = sessionMood?.restless - ? BASE_QUERY_MODIFIERS - : FULL_QUERY_MODIFIERS const baseQuery = cleanSearchQuery(seed.title, seed.author) - const modifier = modifiers[replenishCount % modifiers.length] - const query = modifier ? `${baseQuery} ${modifier}` : baseQuery const cleanedTitle = cleanTitle(seed.title) const cleanedAuthor = cleanAuthor(seed.author) @@ -221,58 +224,35 @@ export async function searchSeedCandidates( } const spotifyQuery = spotifyBase - const engines: QueryType[] = [ - QueryType.SPOTIFY_SEARCH, - QueryType.YOUTUBE_SEARCH, - QueryType.AUTO, - ] - - for (const [idx, engine] of engines.entries()) { - const engineQuery = - engine === QueryType.SPOTIFY_SEARCH ? spotifyQuery : query - try { - const searchResult = await queue.player.search(engineQuery, { - requestedBy: requestedBy ?? undefined, - searchEngine: engine, - }) + try { + const searchResult = await queue.player.search(spotifyQuery, { + requestedBy: requestedBy ?? undefined, + searchEngine: QueryType.SPOTIFY_SEARCH, + }) - const tracks = searchResult.tracks - .filter( - (t) => - !t.durationMS || - t.durationMS <= MAX_AUTOPLAY_DURATION_MS, - ) - .slice(0, SEARCH_RESULTS_LIMIT) + const tracks = searchResult.tracks + .filter( + (t) => + !t.durationMS || + t.durationMS <= MAX_AUTOPLAY_DURATION_MS, + ) + .slice(0, SEARCH_RESULTS_LIMIT) - if (tracks.length > 0) { - if (idx > 0) { - warnLog({ - message: - 'Autoplay: Spotify returned 0 results, using fallback', - data: { - fallbackEngine: engine, - spotifyQuery, - fallbackQuery: engineQuery, - }, - }) - } - return tracks - } - if (engine === QueryType.SPOTIFY_SEARCH) { - debugLog({ - message: 'Autoplay: Spotify search returned 0 results', - data: { spotifyQuery }, - }) - } - } catch (error) { + if (tracks.length === 0) { debugLog({ - message: 'Search failed for seed, trying next engine', - data: { query: engineQuery, engine, error: String(error) }, + message: 'Autoplay: seed search returned 0 results', + data: { spotifyQuery }, }) } - } - return [] + return tracks + } catch (error) { + debugLog({ + message: 'Autoplay: seed search failed', + data: { spotifyQuery, error: String(error) }, + }) + return [] + } } function extractTitleArtistFromSong( diff --git a/packages/bot/src/utils/music/autoplay/stats.spec.ts b/packages/bot/src/utils/music/autoplay/stats.spec.ts index d87f5f4bd..2e3abf199 100644 --- a/packages/bot/src/utils/music/autoplay/stats.spec.ts +++ b/packages/bot/src/utils/music/autoplay/stats.spec.ts @@ -4,14 +4,11 @@ import { expect, beforeEach, jest, - afterEach, } from '@jest/globals' import { getAutoplayStats, shouldEnableAutoplay } from './stats' -const errorLogMock = jest.fn() - jest.mock('@lucky/shared/utils', () => ({ - errorLog: (...args: unknown[]) => errorLogMock(...args), + errorLog: jest.fn(), })) jest.mock('@lucky/shared/services', () => { @@ -31,75 +28,47 @@ describe('autoplay/stats', () => { beforeEach(() => { jest.clearAllMocks() - const { - guildSettingsService, - trackHistoryService, - } = require('@lucky/shared/services') - mockGetAutoplayCounter = - guildSettingsService.getAutoplayCounter as jest.Mock + const { guildSettingsService, trackHistoryService } = require('@lucky/shared/services') + mockGetAutoplayCounter = guildSettingsService.getAutoplayCounter as jest.Mock mockGetTrackHistory = trackHistoryService.getTrackHistory as jest.Mock }) - afterEach(() => { - jest.clearAllMocks() - }) - describe('getAutoplayStats', () => { - test('returns stats with 0 total when counter not found', async () => { + test('returns zeroed stats when counter is null and history is empty', async () => { mockGetAutoplayCounter.mockResolvedValue(null) mockGetTrackHistory.mockResolvedValue([]) const result = await getAutoplayStats('guild-1') - expect(result.total).toBe(0) - expect(result.thisWeek).toBe(0) - expect(result.thisMonth).toBe(0) - expect(result.averagePerDay).toBe(0) - }) - - test('returns total count from counter', async () => { - mockGetAutoplayCounter.mockResolvedValue({ count: 150 }) - mockGetTrackHistory.mockResolvedValue([]) - - const result = await getAutoplayStats('guild-1') - - expect(result.total).toBe(150) + expect(result).toEqual({ total: 0, thisWeek: 0, thisMonth: 0, averagePerDay: 0 }) }) test('calculates this week count correctly', async () => { mockGetAutoplayCounter.mockResolvedValue({ count: 0 }) const now = Date.now() const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000 - const twoWeeksAgo = now - 14 * 24 * 60 * 60 * 1000 - const history = [ + mockGetTrackHistory.mockResolvedValue([ { timestamp: now, trackId: '1' }, { timestamp: oneWeekAgo + 1000, trackId: '2' }, - { timestamp: twoWeeksAgo, trackId: '3' }, - ] - mockGetTrackHistory.mockResolvedValue(history) - - const result = await getAutoplayStats('guild-1') + { timestamp: oneWeekAgo - 1000, trackId: '3' }, + ]) - expect(result.thisWeek).toBe(2) + expect((await getAutoplayStats('guild-1')).thisWeek).toBe(2) }) test('calculates this month count correctly', async () => { mockGetAutoplayCounter.mockResolvedValue({ count: 0 }) const now = Date.now() const oneMonthAgo = now - 30 * 24 * 60 * 60 * 1000 - const twoMonthsAgo = now - 60 * 24 * 60 * 60 * 1000 - const history = [ + mockGetTrackHistory.mockResolvedValue([ { timestamp: now, trackId: '1' }, { timestamp: oneMonthAgo + 1000, trackId: '2' }, - { timestamp: twoMonthsAgo, trackId: '3' }, - ] - mockGetTrackHistory.mockResolvedValue(history) - - const result = await getAutoplayStats('guild-1') + { timestamp: oneMonthAgo - 1000, trackId: '3' }, + ]) - expect(result.thisMonth).toBe(2) + expect((await getAutoplayStats('guild-1')).thisMonth).toBe(2) }) test('calculates average per day from week data', async () => { @@ -107,209 +76,79 @@ describe('autoplay/stats', () => { const now = Date.now() const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000 - const history = [ - { timestamp: now, trackId: '1' }, - { timestamp: now - 1000, trackId: '2' }, - { timestamp: now - 2000, trackId: '3' }, - { timestamp: oneWeekAgo + 1000, trackId: '4' }, - { timestamp: oneWeekAgo + 2000, trackId: '5' }, - { timestamp: oneWeekAgo + 3000, trackId: '6' }, - { timestamp: oneWeekAgo + 4000, trackId: '7' }, - ] + const history = Array.from({ length: 7 }, (_, i) => ({ + timestamp: oneWeekAgo + (i + 1) * 1000, + trackId: String(i), + })) mockGetTrackHistory.mockResolvedValue(history) const result = await getAutoplayStats('guild-1') - expect(result.thisWeek).toBe(7) expect(result.averagePerDay).toBeCloseTo(1) }) - test('returns default stats on error', async () => { + test('returns default stats on service error', async () => { mockGetAutoplayCounter.mockRejectedValue(new Error('Service error')) - const result = await getAutoplayStats('guild-1') - - expect(result).toEqual({ - total: 0, - thisWeek: 0, - thisMonth: 0, - averagePerDay: 0, + expect(await getAutoplayStats('guild-1')).toEqual({ + total: 0, thisWeek: 0, thisMonth: 0, averagePerDay: 0, }) }) - test('logs error when exception occurs', async () => { - mockGetAutoplayCounter.mockRejectedValue(new Error('Service error')) - - await getAutoplayStats('guild-1') - - expect(errorLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Error getting autoplay stats:', - }), - ) - }) - - test('handles empty history gracefully', async () => { - mockGetAutoplayCounter.mockResolvedValue({ count: 100 }) - mockGetTrackHistory.mockResolvedValue([]) - - const result = await getAutoplayStats('guild-1') - - expect(result.thisWeek).toBe(0) - expect(result.thisMonth).toBe(0) - expect(result.averagePerDay).toBe(0) - }) - - test('fetches 100 history entries', async () => { - mockGetAutoplayCounter.mockResolvedValue({ count: 0 }) - mockGetTrackHistory.mockResolvedValue([]) - - await getAutoplayStats('guild-1') - - expect(mockGetTrackHistory).toHaveBeenCalledWith('guild-1', 100) - }) - - test('correctly handles timestamp greater than comparison', async () => { + test('handles boundary timestamps around the week cutoff', async () => { mockGetAutoplayCounter.mockResolvedValue({ count: 0 }) const now = Date.now() const DAY = 24 * 60 * 60 * 1000 - const history = [ - { timestamp: now - 8 * DAY, trackId: '1' }, - { timestamp: now - 6 * DAY, trackId: '2' }, - { timestamp: now - 31 * DAY, trackId: '3' }, - { timestamp: now - 29 * DAY, trackId: '4' }, - ] - mockGetTrackHistory.mockResolvedValue(history) + mockGetTrackHistory.mockResolvedValue([ + { timestamp: now - 6 * DAY, trackId: '1' }, + { timestamp: now - 8 * DAY, trackId: '2' }, + { timestamp: now - 29 * DAY, trackId: '3' }, + { timestamp: now - 31 * DAY, trackId: '4' }, + ]) const result = await getAutoplayStats('guild-1') - - expect(result.thisWeek).toBeGreaterThanOrEqual(1) - expect(result.thisMonth).toBeGreaterThanOrEqual(2) + expect(result.thisWeek).toBe(1) + expect(result.thisMonth).toBe(3) }) }) describe('shouldEnableAutoplay', () => { test('returns true when there is activity this week', async () => { mockGetAutoplayCounter.mockResolvedValue({ count: 0 }) - const now = Date.now() - const oneDayAgo = now - 24 * 60 * 60 * 1000 - mockGetTrackHistory.mockResolvedValue([ - { timestamp: oneDayAgo, trackId: '1' }, + { timestamp: Date.now() - 24 * 60 * 60 * 1000, trackId: '1' }, ]) - - const result = await shouldEnableAutoplay('guild-1') - - expect(result).toBe(true) + expect(await shouldEnableAutoplay('guild-1')).toBe(true) }) - test('returns true when total count is greater than 5', async () => { + test('returns true when total count exceeds 5 with no recent activity', async () => { mockGetAutoplayCounter.mockResolvedValue({ count: 10 }) mockGetTrackHistory.mockResolvedValue([]) - - const result = await shouldEnableAutoplay('guild-1') - - expect(result).toBe(true) + expect(await shouldEnableAutoplay('guild-1')).toBe(true) }) - test('returns false when no activity this week and total is <= 5', async () => { + test('returns false when no activity this week and total is 3', async () => { mockGetAutoplayCounter.mockResolvedValue({ count: 3 }) - const now = Date.now() - const twoWeeksAgo = now - 14 * 24 * 60 * 60 * 1000 - mockGetTrackHistory.mockResolvedValue([ - { timestamp: twoWeeksAgo, trackId: '1' }, + { timestamp: Date.now() - 14 * 24 * 60 * 60 * 1000, trackId: '1' }, ]) - - const result = await shouldEnableAutoplay('guild-1') - - expect(result).toBe(false) + expect(await shouldEnableAutoplay('guild-1')).toBe(false) }) - test('returns false when no history and count is 0', async () => { - mockGetAutoplayCounter.mockResolvedValue({ count: 0 }) + test('boundary: count=5 returns false, count=6 returns true', async () => { mockGetTrackHistory.mockResolvedValue([]) - const result = await shouldEnableAutoplay('guild-1') - - expect(result).toBe(false) - }) - - test('returns false when no history and count is 5', async () => { mockGetAutoplayCounter.mockResolvedValue({ count: 5 }) - mockGetTrackHistory.mockResolvedValue([]) - - const result = await shouldEnableAutoplay('guild-1') + expect(await shouldEnableAutoplay('guild-1')).toBe(false) - expect(result).toBe(false) - }) - - test('returns true when count is 6', async () => { mockGetAutoplayCounter.mockResolvedValue({ count: 6 }) - mockGetTrackHistory.mockResolvedValue([]) - - const result = await shouldEnableAutoplay('guild-1') - - expect(result).toBe(true) - }) - - test('returns false on error', async () => { - mockGetAutoplayCounter.mockRejectedValue(new Error('Service error')) - - const result = await shouldEnableAutoplay('guild-1') - - expect(result).toBe(false) + expect(await shouldEnableAutoplay('guild-1')).toBe(true) }) - test('logs error when exception occurs', async () => { + test('returns false on service error', async () => { mockGetAutoplayCounter.mockRejectedValue(new Error('Service error')) - - await shouldEnableAutoplay('guild-1') - - expect(errorLogMock).toHaveBeenCalled() - }) - - test('enables autoplay with activity this week only', async () => { - mockGetAutoplayCounter.mockResolvedValue({ count: 1 }) - const now = Date.now() - const oneDayAgo = now - 24 * 60 * 60 * 1000 - - mockGetTrackHistory.mockResolvedValue([ - { timestamp: oneDayAgo, trackId: '1' }, - ]) - - const result = await shouldEnableAutoplay('guild-1') - - expect(result).toBe(true) - }) - - test('considers count with 6 as enabling factor', async () => { - mockGetAutoplayCounter.mockResolvedValue({ count: 100 }) - const now = Date.now() - const threeMonthsAgo = now - 90 * 24 * 60 * 60 * 1000 - - mockGetTrackHistory.mockResolvedValue([ - { timestamp: threeMonthsAgo, trackId: '1' }, - ]) - - const result = await shouldEnableAutoplay('guild-1') - - expect(result).toBe(true) - }) - - test('returns true when both conditions met', async () => { - mockGetAutoplayCounter.mockResolvedValue({ count: 100 }) - const now = Date.now() - const oneDayAgo = now - 24 * 60 * 60 * 1000 - - mockGetTrackHistory.mockResolvedValue([ - { timestamp: oneDayAgo, trackId: '1' }, - ]) - - const result = await shouldEnableAutoplay('guild-1') - - expect(result).toBe(true) + expect(await shouldEnableAutoplay('guild-1')).toBe(false) }) }) }) diff --git a/packages/bot/src/utils/music/candidateFallback.spec.ts b/packages/bot/src/utils/music/candidateFallback.spec.ts index 1c54a0ca6..342f171d5 100644 --- a/packages/bot/src/utils/music/candidateFallback.spec.ts +++ b/packages/bot/src/utils/music/candidateFallback.spec.ts @@ -13,6 +13,14 @@ const cleanSearchQueryMock = jest.fn() const cleanAuthorMock = jest.fn() const normalizeTrackKeyMock = jest.fn() const calculateGenreFamilyPenaltyMock = jest.fn() +const artistTagFetcherMock = jest.fn() +const createArtistTagFetcherMock = jest.fn() + +jest.mock('@lucky/shared/utils', () => ({ + debugLog: jest.fn(), + errorLog: jest.fn(), + warnLog: jest.fn(), +})) jest.mock('discord-player', () => ({ QueryType: { SPOTIFY_SEARCH: 'spotify_search', AUTO: 'auto' }, @@ -56,6 +64,10 @@ jest.mock('./trackNormalization', () => ({ calculateGenreFamilyPenalty: (...args: unknown[]) => calculateGenreFamilyPenaltyMock(...args), })) +jest.mock('./autoplay/artistTagCache', () => ({ + createArtistTagFetcher: (...args: unknown[]) => createArtistTagFetcherMock(...args), +})) + import { interleaveByArtist, enrichWithAudioFeatures, @@ -72,7 +84,7 @@ function createScoredTrack(author: string, score = 0.5, url = 'https://url'): Sc durationMS: 200_000, } as never, score, - reason: 'test reason', + basis: { source: 'spotify-rec', signals: [] } as never, } } @@ -207,7 +219,7 @@ describe('enrichWithAudioFeatures', () => { expect(getArtistGenresMock).toHaveBeenCalledTimes(2) expect(calculateGenreFamilyPenaltyMock).toHaveBeenCalledWith(['rock'], ['hip hop']) expect(result[0].score).toBeLessThan(0.5) - expect(result[0].reason).toContain('genre family drift') + expect(result[0].basis.signals).toContain('genre family drift') }) it('looks up candidate genres when currentArtistName genres are non-empty', async () => { @@ -287,7 +299,7 @@ describe('collectGenreCandidates', () => { jest.clearAllMocks() normalizeTrackKeyMock.mockReturnValue('key') shouldIncludeCandidateMock.mockReturnValue(true) - calculateRecommendationScoreMock.mockReturnValue({ score: 0.5, reason: 'genre' }) + calculateRecommendationScoreMock.mockReturnValue({ score: 0.5, signals: [] }) cleanSearchQueryMock.mockImplementation((t: unknown, a: unknown) => `${t} ${a}`) }) @@ -334,7 +346,7 @@ describe('collectGenreCandidates', () => { const ctx = createCtx() // Pre-fill candidates to buffer size (8) for (let i = 0; i < 8; i++) { - ctx.candidates.set(`key-${i}`, { track: {} as never, score: 0.5, reason: '' }) + ctx.candidates.set(`key-${i}`, { track: {} as never, score: 0.5, basis: { source: 'spotify-rec', signals: [] } }) } await collectGenreCandidates(queue, ['rock', 'pop'], { id: 'user-1' } as never, ctx) @@ -361,7 +373,9 @@ describe('collectBroadFallbackCandidates', () => { jest.clearAllMocks() normalizeTrackKeyMock.mockReturnValue('normalized-key') shouldIncludeCandidateMock.mockReturnValue(true) - calculateRecommendationScoreMock.mockReturnValue({ score: 0.5, reason: 'test' }) + calculateRecommendationScoreMock.mockReturnValue({ score: 0.5, signals: [] }) + artistTagFetcherMock.mockResolvedValue([]) + createArtistTagFetcherMock.mockReturnValue((...args: unknown[]) => artistTagFetcherMock(...args)) }) it('does not throw when queue.player.search rejects', async () => { @@ -437,4 +451,185 @@ describe('collectBroadFallbackCandidates', () => { expect(upsertScoredCandidateMock).not.toHaveBeenCalled() }) + + it('fetches artist tags via genreContext.getArtistTags and passes them to scorer', async () => { + const foundTrack = { title: 'Eres Fiel', author: 'Marcos Witt', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Artist', title: 'Song', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockResolvedValue(['latin christian', 'spanish gospel']) + const candidates = new Map() + + await collectBroadFallbackCandidates( + queue, + currentTrack, + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock, currentTrackTags: ['rock'], sessionGenreFamilies: new Set(['rock_metal']) }, + ) + + expect(getArtistTagsMock).toHaveBeenCalledWith('Marcos Witt') + expect(calculateRecommendationScoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + genreContext: expect.objectContaining({ + candidateTags: ['latin christian', 'spanish gospel'], + currentTrackTags: ['rock'], + sessionGenreFamilies: expect.any(Set), + }), + }), + ) + }) + + it('falls back to empty tags when getArtistTags rejects', async () => { + const foundTrack = { title: 'Song', author: 'Artist', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Artist', title: 'Song', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockRejectedValue(new Error('lastfm down')) + const candidates = new Map() + + await expect( + collectBroadFallbackCandidates( + queue, + currentTrack, + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock }, + ), + ).resolves.toBeUndefined() + + expect(calculateRecommendationScoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + genreContext: expect.objectContaining({ candidateTags: [] }), + }), + ) + }) + + it('uses Spotify genres as fallback tags when Last.fm returns empty', async () => { + const foundTrack = { title: 'Hallelujah', author: 'Felipe Dutra', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Coldplay', title: 'Yellow', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockResolvedValue([]) + getValidAccessTokenMock.mockResolvedValue('spotify-token') + getArtistGenresMock.mockResolvedValue(['latin gospel', 'latin christian']) + const candidates = new Map() + + await collectBroadFallbackCandidates( + queue, + currentTrack, + { id: 'user1' } as never, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock }, + ) + + expect(getArtistGenresMock).toHaveBeenCalledWith('spotify-token', 'Felipe Dutra') + expect(calculateRecommendationScoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + genreContext: expect.objectContaining({ + candidateTags: ['latin gospel', 'latin christian'], + }), + }), + ) + }) + + it('does not fetch Spotify genres when Last.fm tags are already populated', async () => { + const foundTrack = { title: 'Song', author: 'Artist', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Artist', title: 'Song', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockResolvedValue(['rock', 'indie']) + getValidAccessTokenMock.mockResolvedValue('spotify-token') + const candidates = new Map() + + await collectBroadFallbackCandidates( + queue, + currentTrack, + { id: 'user1' } as never, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock }, + ) + + expect(getArtistGenresMock).not.toHaveBeenCalled() + }) + + it('does not fetch Spotify genres when no user token is available', async () => { + const foundTrack = { title: 'Hallelujah', author: 'Felipe Dutra', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Artist', title: 'Song', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockResolvedValue([]) + getValidAccessTokenMock.mockResolvedValue(null) + const candidates = new Map() + + await collectBroadFallbackCandidates( + queue, + currentTrack, + { id: 'user1' } as never, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock }, + ) + + expect(getArtistGenresMock).not.toHaveBeenCalled() + }) }) diff --git a/packages/bot/src/utils/music/candidateFallback.ts b/packages/bot/src/utils/music/candidateFallback.ts index 6e3be9e7b..b572fd0c4 100644 --- a/packages/bot/src/utils/music/candidateFallback.ts +++ b/packages/bot/src/utils/music/candidateFallback.ts @@ -1,5 +1,6 @@ import { QueryType, type Track, type GuildQueue } from 'discord-player' import type { User } from 'discord.js' +import { logAndSwallow } from '@lucky/shared/utils/error' import { getBatchAudioFeatures, getArtistGenres, type SpotifyAudioFeatures } from '../../spotify/spotifyApi' import { spotifyLinkService } from '@lucky/shared/services' import { getTagTopTracks } from '../../lastfm' @@ -11,6 +12,8 @@ import { } from './autoplay/candidateCollector' import { calculateRecommendationScore } from './autoplay/candidateScorer' import type { SessionMood } from './autoplay/sessionMood' +import { createArtistTagFetcher, type ArtistTagFetcher } from './autoplay/artistTagCache' +import type { AutoplayAuditCollector } from './autoplay/autoplayAudit' import { cleanSearchQuery, cleanAuthor } from './searchQueryCleaner' import { normalizeTrackKey, calculateGenreFamilyPenalty } from './trackNormalization' @@ -40,6 +43,7 @@ export interface CandidateContext { currentTrackTags?: string[] sessionGenreFamilies?: Set } + auditCollector?: AutoplayAuditCollector } function addGenreTrackCandidate( @@ -73,8 +77,9 @@ function addGenreTrackCandidate( }) upsertScoredCandidate(ctx.candidates, track, { score: rec.score + GENRE_SCORE_BOOST, - reason: rec.reason ? `${rec.reason} • ${tag} vibes` : `${tag} vibes`, - }) + source: 'genre-tag', + signals: rec.signals, + }, ctx.auditCollector) } export async function collectBroadFallbackCandidates( @@ -94,12 +99,30 @@ export async function collectBroadFallbackCandidates( implicitDislikeKeys: Set = new Set(), implicitLikeKeys: Set = new Set(), sessionMood: SessionMood | null = null, + genreContext: { + getArtistTags?: ArtistTagFetcher + currentTrackTags?: string[] + sessionGenreFamilies?: Set + } = {}, + auditCollector?: AutoplayAuditCollector, ): Promise { + const getArtistTags = genreContext.getArtistTags ?? createArtistTagFetcher() + const currentTrackTags = genreContext.currentTrackTags ?? [] + const sessionGenreFamilies = genreContext.sessionGenreFamilies ?? new Set() + const fallbackQueries = [ currentTrack.author, `${currentTrack.author} popular`, ].filter(Boolean) + // Obtain Spotify token once for the whole fallback pass — used to enrich + // genre tags when Last.fm is not linked (getArtistTags returns []). + // Promise.resolve() guards against stubs that return undefined rather than + // a Promise (matches the pattern used throughout replenisher.ts). + const spotifyToken = requestedBy?.id // NOSONAR — userId is an internal Discord snowflake, not user-controlled URL data + ? await Promise.resolve(spotifyLinkService.getValidAccessToken(requestedBy.id)).catch(() => null) + : null + for (const query of fallbackQueries) { try { const result = await queue.player.search(query, { @@ -122,6 +145,20 @@ export async function collectBroadFallbackCandidates( const dislikedWeight = dislikedWeights.get(key) if (dislikedWeight !== undefined && dislikedWeight > 0.5) continue + let candidateTags = await getArtistTags(track.author).catch((err: unknown) => { + logAndSwallow(err, 'candidateFallback.getArtistTags', { author: track.author }) + return [] as string[] + }) + // When Last.fm returns nothing (not linked or artist unknown), + // use Spotify's genre data so the cross-locale Spanish veto in + // candidateScorer can still fire for Spanish gospel artists + // that have non-Spanish-looking artist names / titles. + if (candidateTags.length === 0 && spotifyToken) { + candidateTags = await getArtistGenres(spotifyToken, track.author).catch((err: unknown) => { + logAndSwallow(err, 'candidateFallback.getArtistGenres.spotifyFallback', { author: track.author }) + return [] as string[] + }) // NOSONAR — track.author used as URLSearchParams search query inside getArtistGenres; no raw URL interpolation + } const rec = calculateRecommendationScore({ candidate: track, currentTrack, @@ -135,16 +172,21 @@ export async function collectBroadFallbackCandidates( implicitLikeKeys, dislikedWeights, sessionMood, + genreContext: { + candidateTags, + currentTrackTags, + sessionGenreFamilies, + }, }) upsertScoredCandidate(candidates, track, { score: rec.score - 0.1, - reason: rec.reason - ? `${rec.reason} • artist fallback` - : 'artist fallback', - }) + source: 'artist-fallback', + signals: rec.signals, + }, auditCollector) } - } catch { + } catch (err: unknown) { + logAndSwallow(err, 'candidateFallback.spotifySearch', { query }) continue } } @@ -161,7 +203,8 @@ export async function collectGenreCandidates( let seeds: Awaited> = [] try { seeds = await getTagTopTracks(tag, MAX_TRACKS_PER_GENRE) - } catch { + } catch (err: unknown) { + logAndSwallow(err, 'candidateFallback.getTagTopTracks', { tag }) continue } for (const seed of seeds) { @@ -173,7 +216,8 @@ export async function collectGenreCandidates( requestedBy, ) for (const track of results) addGenreTrackCandidate(track, tag, ctx) - } catch { + } catch (err: unknown) { + logAndSwallow(err, 'candidateFallback.searchLastFmQuery', { tag, seed: `${seed.artist}/${seed.title}` }) continue } } @@ -238,15 +282,18 @@ export async function enrichWithAudioFeatures( const candidateGenres = await getArtistGenres( token, track.track.author, - ).catch(() => []) + ).catch((err: unknown) => { + logAndSwallow(err, 'candidateFallback.getArtistGenres', { author: track.track.author }) + return [] + }) const genrePenalty = calculateGenreFamilyPenalty( currentGenres, candidateGenres, ) if (genrePenalty !== 0) { track.score += genrePenalty - if (genrePenalty < -0.3) { - track.reason += ' • genre family drift' + if (genrePenalty <= -0.3 && !track.basis.signals.includes('genre family drift')) { + track.basis.signals.push('genre family drift') } } } diff --git a/packages/bot/src/utils/music/collaborativePlaylist.spec.ts b/packages/bot/src/utils/music/collaborativePlaylist.spec.ts deleted file mode 100644 index 29398195f..000000000 --- a/packages/bot/src/utils/music/collaborativePlaylist.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from '@jest/globals' -import { collaborativePlaylistService } from './collaborativePlaylist' - -describe('collaborativePlaylistService', () => { - it('enforces per-user limits when collaborative mode is enabled', () => { - collaborativePlaylistService.setMode('guild-1', true, 2) - collaborativePlaylistService.resetContributions('guild-1') - - const first = collaborativePlaylistService.canAddTracks( - 'guild-1', - 'user-1', - 1, - ) - expect(first.allowed).toBe(true) - - collaborativePlaylistService.recordContribution('guild-1', 'user-1', 1) - const second = collaborativePlaylistService.canAddTracks( - 'guild-1', - 'user-1', - 2, - ) - expect(second.allowed).toBe(false) - }) - - it('always allows additions when collaborative mode is disabled', () => { - collaborativePlaylistService.setMode('guild-2', false, 1) - collaborativePlaylistService.resetContributions('guild-2') - collaborativePlaylistService.recordContribution('guild-2', 'user-2', 4) - - const result = collaborativePlaylistService.canAddTracks( - 'guild-2', - 'user-2', - 4, - ) - expect(result.allowed).toBe(true) - }) -}) diff --git a/packages/bot/src/utils/music/idleDisconnect.spec.ts b/packages/bot/src/utils/music/idleDisconnect.spec.ts deleted file mode 100644 index da0694273..000000000 --- a/packages/bot/src/utils/music/idleDisconnect.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals' -import type { GuildQueue } from 'discord-player' -import type { TextChannel, Guild } from 'discord.js' - -const getGuildSettingsMock = jest.fn() -const markIntentionalStopMock = jest.fn() - -jest.mock('@lucky/shared/services', () => ({ - guildSettingsService: { - getGuildSettings: (...args: unknown[]) => getGuildSettingsMock(...args), - }, -})) - -jest.mock('@lucky/shared/utils', () => ({ - debugLog: jest.fn(), -})) - -jest.mock('./watchdog', () => ({ - musicWatchdogService: { - markIntentionalStop: (...args: unknown[]) => markIntentionalStopMock(...args), - }, -})) - -describe('idleDisconnect', () => { - let mockQueue: Partial - let mockGuild: Partial - let mockChannel: Partial - let scheduleIdleDisconnect: any - let clearIdleTimer: any - - beforeEach(async () => { - jest.useFakeTimers() - jest.clearAllMocks() - - mockGuild = { id: 'guild-123' } - mockChannel = { send: jest.fn().mockResolvedValue(undefined) } - mockQueue = { - guild: mockGuild as Guild, - delete: jest.fn(), - metadata: { channel: mockChannel }, - } as unknown as GuildQueue - - const module = await import('./idleDisconnect') - scheduleIdleDisconnect = module.scheduleIdleDisconnect - clearIdleTimer = module.clearIdleTimer - }) - - afterEach(() => { - jest.useRealTimers() - }) - - it('should not schedule timer when idleTimeoutMinutes is 0', async () => { - getGuildSettingsMock.mockResolvedValue({ idleTimeoutMinutes: 0 }) - - scheduleIdleDisconnect(mockQueue as GuildQueue) - await jest.runAllTimersAsync() - - expect(mockQueue.delete).not.toHaveBeenCalled() - }) - - it('should schedule timer when idleTimeoutMinutes is set', async () => { - getGuildSettingsMock.mockResolvedValue({ idleTimeoutMinutes: 5 }) - - scheduleIdleDisconnect(mockQueue as GuildQueue) - await jest.runOnlyPendingTimersAsync() - jest.advanceTimersByTime(5 * 60 * 1000) - - expect(mockQueue.delete).toHaveBeenCalled() - }) - - it('should mark intentional stop before disconnecting', async () => { - getGuildSettingsMock.mockResolvedValue({ idleTimeoutMinutes: 1 }) - - scheduleIdleDisconnect(mockQueue as GuildQueue) - await jest.runOnlyPendingTimersAsync() - jest.advanceTimersByTime(1 * 60 * 1000) - - expect(markIntentionalStopMock).toHaveBeenCalledWith('guild-123') - }) - - it('should send message to text channel on disconnect', async () => { - getGuildSettingsMock.mockResolvedValue({ idleTimeoutMinutes: 1 }) - - scheduleIdleDisconnect(mockQueue as GuildQueue) - await jest.runOnlyPendingTimersAsync() - jest.advanceTimersByTime(1 * 60 * 1000) - - expect(mockChannel.send).toHaveBeenCalledWith('👋 Left the voice channel due to inactivity.') - }) - - it('should clear existing timer when scheduling new one', async () => { - getGuildSettingsMock.mockResolvedValue({ idleTimeoutMinutes: 5 }) - - scheduleIdleDisconnect(mockQueue as GuildQueue) - await jest.runOnlyPendingTimersAsync() - jest.clearAllMocks() - - scheduleIdleDisconnect(mockQueue as GuildQueue) - await jest.runOnlyPendingTimersAsync() - jest.advanceTimersByTime(5 * 60 * 1000) - - expect(mockQueue.delete).toHaveBeenCalledTimes(1) - }) - - it('clearIdleTimer should clear active timer', async () => { - getGuildSettingsMock.mockResolvedValue({ idleTimeoutMinutes: 5 }) - - scheduleIdleDisconnect(mockQueue as GuildQueue) - await jest.runOnlyPendingTimersAsync() - - clearIdleTimer('guild-123') - jest.clearAllMocks() - jest.advanceTimersByTime(5 * 60 * 1000) - - expect(mockQueue.delete).not.toHaveBeenCalled() - }) -}) diff --git a/packages/bot/src/utils/music/languageHeuristics.spec.ts b/packages/bot/src/utils/music/languageHeuristics.spec.ts index bf0bf1b85..eaff49b2c 100644 --- a/packages/bot/src/utils/music/languageHeuristics.spec.ts +++ b/packages/bot/src/utils/music/languageHeuristics.spec.ts @@ -74,6 +74,29 @@ describe('languageHeuristics', () => { ).toBe(false) }) + it('should detect Spanish worship titles without ñ/¿/¡', () => { + // These slip through when Last.fm tags are absent — common gospel titles + expect(detectSpanishMarkers('Eres Fiel', undefined)).toBe(true) + expect(detectSpanishMarkers('Fuego de Tu Presencia', undefined)).toBe(true) + expect(detectSpanishMarkers('Los Cielos Cuentan', undefined)).toBe(true) + expect(detectSpanishMarkers('Tiempo de Alabanza', undefined)).toBe(true) + expect(detectSpanishMarkers('Tu Gracia', undefined)).toBe(true) + expect(detectSpanishMarkers('Gracias a Dios', undefined)).toBe(true) + expect(detectSpanishMarkers('Nuevo Corazon', undefined)).toBe(true) + expect(detectSpanishMarkers('Pueblo de Dios', undefined)).toBe(true) + expect(detectSpanishMarkers('Llena Mi Copa', undefined)).toBe(true) + expect(detectSpanishMarkers('Una Noche Mas', undefined)).toBe(true) + }) + + it('should not flag English or Portuguese tracks on new tokens', () => { + expect(detectSpanishMarkers('Amazing Grace', undefined)).toBe(false) + expect(detectSpanishMarkers('Hillsong United', undefined)).toBe(false) + // Portuguese "presença" uses ç — won't match "presencia" + expect(detectSpanishMarkers('Presença de Deus', undefined)).toBe(false) + // Portuguese "graça" uses ç — won't match "gracia" + expect(detectSpanishMarkers('Graça e Paz', undefined)).toBe(false) + }) + it('should let Portuguese veto win when both signals appear', () => { // Brazilian artist Anitta has English/Spanish/Portuguese mixes. // "El Que Espera Por Mim" — has Spanish stopwords ("el") but @@ -82,6 +105,46 @@ describe('languageHeuristics', () => { detectSpanishMarkers('Não te quiero pero também não te perdono', undefined), ).toBe(false) }) + + it('should detect new Spanish worship genre markers', () => { + expect(detectSpanishMarkers(undefined, ['latin worship'])).toBe(true) + expect(detectSpanishMarkers(undefined, ['ccm en español'])).toBe(true) + expect(detectSpanishMarkers(undefined, ['spanish ccm'])).toBe(true) + }) + + it('should detect new Spanish gospel distinct tokens', () => { + expect(detectSpanishMarkers('eres mi todo', undefined)).toBe(true) + expect(detectSpanishMarkers('nuestro padre celestial', undefined)).toBe(true) + expect(detectSpanishMarkers('nuestra esperanza', undefined)).toBe(true) + expect(detectSpanishMarkers('nuestros corazones', undefined)).toBe(true) + expect(detectSpanishMarkers('nuestras vidas', undefined)).toBe(true) + expect(detectSpanishMarkers('siervo fiel', undefined)).toBe(true) + expect(detectSpanishMarkers('sierva del señor', undefined)).toBe(true) + expect(detectSpanishMarkers('digno de alabanza', undefined)).toBe(true) + expect(detectSpanishMarkers('fuego de dios', undefined)).toBe(true) + expect(detectSpanishMarkers('reina en los cielos', undefined)).toBe(true) + }) + + it('should not false-positive English text with new tokens', () => { + // "eres" is not an English word + expect(detectSpanishMarkers('Greatest Hits Vol 1', undefined)).toBe(false) + // "fuego" does not appear in ordinary English titles + expect(detectSpanishMarkers('Fire and Rain', undefined)).toBe(false) + }) + + it('should not classify Portuguese text containing overlapping words as Spanish', () => { + // "nosso" (not "nuestro") is the Portuguese equivalent; must not match + expect(detectSpanishMarkers('O Nosso Amor', undefined)).toBe(false) + // "servo" is the Portuguese word, not "siervo"; must not match + expect(detectSpanishMarkers('Servo Fiel do Senhor', undefined)).toBe(false) + }) + + it('should detect Spanish gospel title via multiple new tokens together', () => { + // "Eres digno" — common Spanish worship song title fragment + expect(detectSpanishMarkers('Eres Digno y Santo', undefined)).toBe(true) + // "Nuestro Dios" — title of a popular Spanish worship song + expect(detectSpanishMarkers('Nuestro Dios', undefined)).toBe(true) + }) }) describe('detectPortugueseMarkers', () => { diff --git a/packages/bot/src/utils/music/languageHeuristics.ts b/packages/bot/src/utils/music/languageHeuristics.ts index 50e992431..ba25a55c2 100644 --- a/packages/bot/src/utils/music/languageHeuristics.ts +++ b/packages/bot/src/utils/music/languageHeuristics.ts @@ -22,6 +22,9 @@ const SPANISH_GENRE_MARKERS = [ 'latin christian', 'latin gospel', 'spanish gospel', + 'latin worship', + 'ccm en español', + 'spanish ccm', 'reggaetón', 'tex-mex', 'norteño', @@ -66,6 +69,24 @@ const SPANISH_DISTINCT_TOKENS = [ 'niño', 'niña', 'niños', 'niñas', 'tu gloria', 'tus alabanzas', + // Spanish worship music words whose Portuguese equivalents are spelled + // differently — catches gospel/worship titles that contain no ñ/¿/¡. + 'fuego', // Portuguese: fogo + 'cielo', 'cielos',// Portuguese: céu/céus + 'presencia', // Portuguese: presença (always has ç) + 'alabanza', 'alabanzas', // Portuguese: louvor/louvores + 'alabar', // Portuguese: louvar + 'gracia', 'gracias', // Portuguese: graça / obrigado (different roots entirely) + 'eres', // Portuguese: és + 'nuevo', 'nueva', // Portuguese: novo/nova (different vowel pattern) + 'pueblo', // Portuguese: povo + 'tierra', // Portuguese: terra ("ie" diphthong is Spanish-specific) + 'llena', 'llenas', 'lleno', // Portuguese: cheia/cheio (double-l never in Portuguese) + 'noche', // Portuguese: noite + 'hoy', // Portuguese: hoje + 'nuestro', 'nuestra', 'nuestros', 'nuestras', // Portuguese: nosso/nossa + 'siervo', 'sierva', // Portuguese: servo/serva + 'digno', // Portuguese: digno (same) — but rarely in PT worship titles ] // Words distinctive to Portuguese (Brazilian or European). Hits here veto @@ -106,14 +127,28 @@ const PORTUGUESE_DISTINCT_TOKENS = [ 'glória', ] +// Literal regex — not dynamic, never built from variables. +const LETTER_RE = /[a-záéíóúüñãõç]/i + +function hasWordMatch(text: string, token: string): boolean { + let i = text.indexOf(token) + while (i !== -1) { + const before = i > 0 ? text[i - 1] : '' + const after = i + token.length < text.length ? text[i + token.length] : '' + if ((!before || !LETTER_RE.test(before)) && (!after || !LETTER_RE.test(after))) { + return true + } + i = text.indexOf(token, i + 1) + } + return false +} + function countMatches(text: string, tokens: string[]): number { if (!text) return 0 const lower = text.toLowerCase() let count = 0 for (const token of tokens) { - const escaped = token.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') - const re = new RegExp(`(?:^|[^a-záéíóúüñãõç])${escaped}(?:$|[^a-záéíóúüñãõç])`, 'i') - if (re.test(lower)) count++ + if (hasWordMatch(lower, token.toLowerCase())) count++ } return count } diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index a1eae6680..73acc2389 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -69,6 +69,7 @@ jest.mock('@lucky/shared/services', () => ({ }, spotifyLinkService: { getValidAccessToken: jest.fn().mockResolvedValue(null), + getByDiscordId: jest.fn().mockResolvedValue(null), }, premiumService: { isPremium: jest.fn(() => Promise.resolve(false)), @@ -79,6 +80,7 @@ const consumeLastFmSeedSliceMock = jest.fn() const consumeBlendedSeedSliceMock = jest.fn() jest.mock('./autoplay/lastFmSeeds', () => ({ + LASTFM_SEED_COUNT: 15, consumeLastFmSeedSlice: (...args: unknown[]) => consumeLastFmSeedSliceMock(...args), consumeBlendedSeedSlice: (...args: unknown[]) => @@ -191,9 +193,9 @@ describe('queueManipulation.replenishQueue', () => { }): Promise { const queue = createQueueMock({ currentTrack: { - title: 'Song A', - author: 'Artist A', - url: 'https://example.com/a', + title: 'Bohemian Rhapsody', + author: 'Queen', + url: 'https://example.com/bohemian', } as unknown as Track, metadata: options.queueRequestedById ? { requestedBy: { id: options.queueRequestedById } } @@ -206,10 +208,10 @@ describe('queueManipulation.replenishQueue', () => { search: jest.fn().mockResolvedValue({ tracks: [ { - title: options.candidateTitle ?? 'Song B', - author: options.candidateAuthor ?? 'Artist B', + title: options.candidateTitle ?? 'Stairway to Heaven', + author: options.candidateAuthor ?? 'Led Zeppelin', url: - options.candidateUrl ?? 'https://example.com/b', + options.candidateUrl ?? 'https://example.com/stairway', metadata: options.candidateMetadata ?? {}, }, ], @@ -227,9 +229,9 @@ describe('queueManipulation.replenishQueue', () => { size: 1, toArray: jest.fn().mockReturnValue([ { - title: 'Queued Song', - author: 'Queued Artist', - url: 'https://example.com/q', + title: 'Highway to Hell', + author: 'AC/DC', + url: 'https://example.com/highway', }, ]), }, @@ -237,24 +239,24 @@ describe('queueManipulation.replenishQueue', () => { search: jest.fn().mockResolvedValue({ tracks: [ { - title: 'Song B', - author: 'Artist B', - url: 'https://example.com/b', + title: 'Stairway to Heaven', + author: 'Led Zeppelin', + url: 'https://example.com/stairway', }, { - title: 'Song C', - author: 'Artist C', - url: 'https://example.com/c', + title: 'Smells Like Teen Spirit', + author: 'Nirvana', + url: 'https://example.com/nirvana', }, { - title: 'Song D', - author: 'Artist D', - url: 'https://example.com/d', + title: 'Purple Rain', + author: 'Prince', + url: 'https://example.com/prince', }, { - title: 'Song E', - author: 'Artist E', - url: 'https://example.com/e', + title: 'Imagine', + author: 'John Lennon', + url: 'https://example.com/lennon', }, ], }), @@ -277,8 +279,17 @@ describe('queueManipulation.replenishQueue', () => { }) it('does not search when queue already has buffer size', async () => { + const autoplayTracks = Array.from({ length: 8 }, (_, i) => ({ + title: `Autoplay Track ${i + 1}`, + author: `Artist ${i + 1}`, + url: `https://example.com/autoplay-${i + 1}`, + metadata: { isAutoplay: true }, + })) const queue = createQueueMock({ - tracks: { size: 8, toArray: jest.fn().mockReturnValue([]) }, + tracks: { + size: 8, + toArray: jest.fn().mockReturnValue(autoplayTracks), + }, }) await replenishQueue(queue as unknown as GuildQueue) @@ -344,21 +355,21 @@ describe('queueManipulation.replenishQueue', () => { it.each([ { - name: 'when Spotify and AUTO search throw', - spotifySearch: () => + name: 'when Spotify seed search throws', + seedSearch: () => Promise.reject(new Error('Spotify unavailable')), - autoSearch: () => Promise.reject(new Error('AUTO parser failed')), + artistSearch: () => Promise.reject(new Error('Artist search failed')), fallbackUrl: 'https://example.com/fallback', }, { - name: 'when Spotify and AUTO search return no tracks', - spotifySearch: () => Promise.resolve({ tracks: [] }), - autoSearch: () => Promise.resolve({ tracks: [] }), + name: 'when Spotify seed search returns no tracks', + seedSearch: () => Promise.resolve({ tracks: [] }), + artistSearch: () => Promise.resolve({ tracks: [] }), fallbackUrl: 'https://example.com/recovered', }, ])( - 'falls back to YouTube search $name', - async ({ spotifySearch, autoSearch, fallbackUrl }) => { + 'falls through to artist fallback $name', + async ({ seedSearch, artistSearch, fallbackUrl }) => { const queue = createQueueMock({ tracks: { size: 0, @@ -367,8 +378,8 @@ describe('queueManipulation.replenishQueue', () => { player: { search: jest .fn() - .mockImplementationOnce(spotifySearch) - .mockImplementationOnce(autoSearch) + .mockImplementationOnce(seedSearch) + .mockImplementationOnce(artistSearch) .mockResolvedValueOnce({ tracks: [ { @@ -392,16 +403,9 @@ describe('queueManipulation.replenishQueue', () => { ) expect(queue.player.search).toHaveBeenNthCalledWith( 2, - expect.stringContaining('Song A Artist A'), - expect.objectContaining({ - searchEngine: QueryType.YOUTUBE_SEARCH, - }), - ) - expect(queue.player.search).toHaveBeenNthCalledWith( - 3, - expect.stringContaining('Song A Artist A'), + 'Artist A', expect.objectContaining({ - searchEngine: QueryType.AUTO, + searchEngine: QueryType.SPOTIFY_SEARCH, }), ) expect(queue.addTrack).toHaveBeenCalledWith( @@ -629,9 +633,9 @@ describe('queueManipulation.replenishQueue', () => { it('returns without adding tracks when candidate set is exhausted', async () => { const queue = await replenishWithSingleCandidate({ - candidateTitle: 'Song A clone', - candidateAuthor: 'Artist A', - candidateUrl: 'https://example.com/a', + candidateTitle: 'Bohemian Rhapsody', + candidateAuthor: 'Queen', + candidateUrl: 'https://example.com/bohemian', }) expect(queue.addTrack).not.toHaveBeenCalled() @@ -1163,9 +1167,7 @@ describe('queueManipulation.replenishQueue', () => { it('uses broad artist fallback when seed search returns no candidates', async () => { const searchMock = jest .fn() - // Seed search tries 3 engines (SPOTIFY → AUTO → YOUTUBE) — all empty - .mockResolvedValueOnce({ tracks: [] }) - .mockResolvedValueOnce({ tracks: [] }) + // Seed search (Spotify only) — returns empty .mockResolvedValueOnce({ tracks: [] }) // Broad fallback by author — returns a candidate .mockResolvedValueOnce({ @@ -1214,9 +1216,7 @@ describe('queueManipulation.replenishQueue', () => { it('swallows broad fallback search errors and tries the next query', async () => { const searchMock = jest .fn() - // Seed search: all 3 engines empty - .mockResolvedValueOnce({ tracks: [] }) - .mockResolvedValueOnce({ tracks: [] }) + // Seed search (Spotify only) — returns empty .mockResolvedValueOnce({ tracks: [] }) // Broad fallback: first query ("Artist A") throws .mockRejectedValueOnce(new Error('Network blip')) @@ -3207,6 +3207,7 @@ describe('queueManipulation — multi-user VC blend', () => { it('adds spotify recommendation results as scored candidates', async () => { const spotifyApiMock = jest.requireMock('../../spotify/spotifyApi') as { getSpotifyRecommendations: jest.Mock + getArtistGenres: jest.Mock } const sharedMocks = jest.requireMock('@lucky/shared/services') as { spotifyLinkService: { getValidAccessToken: jest.Mock } @@ -3214,6 +3215,7 @@ describe('queueManipulation — multi-user VC blend', () => { sharedMocks.spotifyLinkService.getValidAccessToken.mockResolvedValue( 'tok-recs', ) + spotifyApiMock.getArtistGenres.mockResolvedValue([]) spotifyApiMock.getSpotifyRecommendations.mockResolvedValue([ { id: 'rectrack1', @@ -3544,6 +3546,7 @@ describe('queueManipulation — multi-user VC blend', () => { const sharedMocks = jest.requireMock('@lucky/shared/services') as any sharedMocks.spotifyLinkService.getValidAccessToken .mockResolvedValueOnce('token-for-current') + .mockResolvedValueOnce(null) // spotifyToken for artist tag fetcher .mockResolvedValueOnce(null) // collectSpotifyRecommendationCandidates .mockResolvedValueOnce('token-for-enrich') @@ -3763,7 +3766,7 @@ describe('queueManipulation — within-cycle dedup via extractSongCore', () => { expect(haloTracks).toHaveLength(1) }) - it('logs warnLog when primary Spotify search returns no results and falls back', async () => { + it('logs debugLog when primary Spotify seed search returns no results', async () => { const fallbackTrack = { title: 'Fallback Song', author: 'Fallback Artist', @@ -3789,12 +3792,12 @@ describe('queueManipulation — within-cycle dedup via extractSongCore', () => { await replenishQueue(queue as unknown as GuildQueue) - const { warnLog } = jest.requireMock('@lucky/shared/utils') as { - warnLog: jest.Mock + const { debugLog } = jest.requireMock('@lucky/shared/utils') as { + debugLog: jest.Mock } - expect(warnLog).toHaveBeenCalledWith( + expect(debugLog).toHaveBeenCalledWith( expect.objectContaining({ - message: expect.stringContaining('Spotify returned 0 results'), + message: expect.stringContaining('seed search returned 0 results'), }), ) }) @@ -4370,7 +4373,7 @@ describe('queueManipulation — diversity improvements', () => { { track: { title: 'T', author: 'A', url: 'https://spotify.com' }, score: 1, - reason: 'test', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ] const result = await enrichWithAudioFeatures(tracks, 'u1', null) @@ -4382,7 +4385,7 @@ describe('queueManipulation — diversity improvements', () => { { track: { title: 'T', author: 'A', url: 'https://spotify.com' }, score: 1, - reason: 'test', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ] const result = await enrichWithAudioFeatures( @@ -4398,7 +4401,7 @@ describe('queueManipulation — diversity improvements', () => { { track: { title: 'T', author: 'A', url: 'https://youtube.com' }, score: 1, - reason: 'test', + basis: { source: 'spotify-rec' as const, signals: [] }, }, ] const result = await enrichWithAudioFeatures( diff --git a/packages/bot/src/utils/music/queueStateManager.spec.ts b/packages/bot/src/utils/music/queueStateManager.spec.ts index 66882cc0c..cceeb129d 100644 --- a/packages/bot/src/utils/music/queueStateManager.spec.ts +++ b/packages/bot/src/utils/music/queueStateManager.spec.ts @@ -153,13 +153,6 @@ describe('queueStateManager', () => { expect(state.position).toBe(0) }) - it('should call node.isPlaying() and node.isPaused()', () => { - getQueueState(mockQueue) - - expect(mockQueue.node?.isPlaying).toHaveBeenCalled() - expect(mockQueue.node?.isPaused).toHaveBeenCalled() - }) - it('should return default state when exception occurs', () => { ;(mockQueue.node?.isPlaying as jest.Mock).mockImplementation(() => { throw new Error('Queue error') diff --git a/packages/bot/src/utils/music/service.spec.ts b/packages/bot/src/utils/music/service.spec.ts index 694322814..b0ecd8ce8 100644 --- a/packages/bot/src/utils/music/service.spec.ts +++ b/packages/bot/src/utils/music/service.spec.ts @@ -512,7 +512,6 @@ describe('TrackManagementService', () => { const result = service.getQueueState(queue) expect(result).toEqual(mockState) - expect(queueStateMgrMock.getQueueState).toHaveBeenCalledWith(queue) }) }) @@ -528,20 +527,10 @@ describe('TrackManagementService', () => { const result = service.getQueueStats(queue) expect(result).toEqual(mockStats) - expect(queueStateMgrMock.getQueueStats).toHaveBeenCalledWith(queue) }) }) describe('isQueueEmpty', () => { - it('delegates to queueStateManager', () => { - queueStateMgrMock.isQueueEmpty.mockReturnValue(false) - - const result = service.isQueueEmpty(queue) - - expect(result).toBe(false) - expect(queueStateMgrMock.isQueueEmpty).toHaveBeenCalledWith(queue) - }) - it('returns true for empty queue', () => { queueStateMgrMock.isQueueEmpty.mockReturnValue(true) @@ -552,15 +541,6 @@ describe('TrackManagementService', () => { }) describe('isQueueFull', () => { - it('delegates to queueStateManager with maxQueueSize', () => { - queueStateMgrMock.isQueueFull.mockReturnValue(false) - - const result = service.isQueueFull(queue) - - expect(result).toBe(false) - expect(queueStateMgrMock.isQueueFull).toHaveBeenCalledWith(queue, 100) - }) - it('uses custom maxQueueSize from options', () => { const customService = new TrackManagementService({ maxQueueSize: 50, @@ -570,20 +550,10 @@ describe('TrackManagementService', () => { const result = customService.isQueueFull(queue) expect(result).toBe(true) - expect(queueStateMgrMock.isQueueFull).toHaveBeenCalledWith(queue, 50) }) }) describe('clearQueue', () => { - it('delegates to queueOperations', async () => { - queueOpsMock.clearQueue.mockResolvedValue(true) - - const result = await service.clearQueue(queue) - - expect(result).toBe(true) - expect(queueOpsMock.clearQueue).toHaveBeenCalledWith(queue) - }) - it('returns false when clear fails', async () => { queueOpsMock.clearQueue.mockResolvedValue(false) @@ -594,15 +564,6 @@ describe('TrackManagementService', () => { }) describe('shuffleQueue', () => { - it('delegates to queueOperations', async () => { - queueOpsMock.shuffleQueue.mockResolvedValue(true) - - const result = await service.shuffleQueue(queue) - - expect(result).toBe(true) - expect(queueOpsMock.shuffleQueue).toHaveBeenCalledWith(queue) - }) - it('returns false when shuffle fails', async () => { queueOpsMock.shuffleQueue.mockResolvedValue(false) @@ -613,16 +574,6 @@ describe('TrackManagementService', () => { }) describe('removeTrackFromQueue', () => { - it('delegates to queueOperations with position', async () => { - const removedTrack = createTrack('removed', 'Removed Song') - queueOpsMock.removeTrackFromQueue.mockResolvedValue(removedTrack) - - const result = await service.removeTrackFromQueue(queue, 2) - - expect(result).toEqual(removedTrack) - expect(queueOpsMock.removeTrackFromQueue).toHaveBeenCalledWith(queue, 2) - }) - it('returns null when track not found', async () => { queueOpsMock.removeTrackFromQueue.mockResolvedValue(null) @@ -633,16 +584,6 @@ describe('TrackManagementService', () => { }) describe('moveTrackInQueue', () => { - it('delegates to queueOperations with from and to positions', async () => { - const movedTrack = createTrack('moved', 'Moved Song') - queueOpsMock.moveTrackInQueue.mockResolvedValue(movedTrack) - - const result = await service.moveTrackInQueue(queue, 1, 3) - - expect(result).toEqual(movedTrack) - expect(queueOpsMock.moveTrackInQueue).toHaveBeenCalledWith(queue, 1, 3) - }) - it('returns null when move operation fails', async () => { queueOpsMock.moveTrackInQueue.mockResolvedValue(null) @@ -674,20 +615,6 @@ describe('TrackManagementService', () => { expect(options.allowDuplicates).toBe(false) }) - it('logs option update', () => { - service.updateOptions({ maxQueueSize: 75 }) - - expect(debugLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Updated track management options', - data: expect.objectContaining({ - options: expect.objectContaining({ - maxQueueSize: 75, - }), - }), - }), - ) - }) it('allows multiple sequential updates', () => { service.updateOptions({ maxQueueSize: 150 }) diff --git a/packages/bot/src/utils/music/sessionStartupRestore.spec.ts b/packages/bot/src/utils/music/sessionStartupRestore.spec.ts deleted file mode 100644 index cc7596477..000000000 --- a/packages/bot/src/utils/music/sessionStartupRestore.spec.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals' -import { restoreSessionsOnStartup } from './sessionStartupRestore' - -// --- mocks --- - -const keysMock = jest.fn() -const getSnapshotMock = jest.fn() -const deleteSnapshotMock = jest.fn() -const restoreSnapshotMock = jest.fn() - -jest.mock('@lucky/shared/config', () => ({ - ENVIRONMENT_CONFIG: { - MUSIC: { SESSION_RESTORE_ENABLED: true }, - }, -})) - -jest.mock('@lucky/shared/services', () => ({ - redisClient: { keys: (...args: unknown[]) => keysMock(...args) }, -})) - -jest.mock('@lucky/shared/utils', () => ({ - infoLog: jest.fn(), - warnLog: jest.fn(), - errorLog: jest.fn(), -})) - -jest.mock('./sessionSnapshots', () => ({ - musicSessionSnapshotService: { - getSnapshot: (...args: unknown[]) => getSnapshotMock(...args), - deleteSnapshot: (...args: unknown[]) => deleteSnapshotMock(...args), - restoreSnapshot: (...args: unknown[]) => restoreSnapshotMock(...args), - }, -})) - -// --- helpers --- - -const GUILD_ID = 'guild-123' -const VOICE_CHANNEL_ID = 'vc-456' -const FRESH_SAVED_AT = Date.now() - 5 * 60 * 1_000 // 5 min ago -const STALE_SAVED_AT = Date.now() - 35 * 60 * 1_000 // 35 min ago (> 30 min limit) - -const makeSnapshot = (overrides: Record = {}) => ({ - sessionSnapshotId: 'snap-1', - guildId: GUILD_ID, - savedAt: FRESH_SAVED_AT, - currentTrack: null, - upcomingTracks: [{ title: 'Track 1', author: 'Artist', url: 'https://yt', duration: '3:00', source: 'youtube' }], - voiceChannelId: VOICE_CHANNEL_ID, - ...overrides, -}) - -const makeVoiceChannel = () => ({ - id: VOICE_CHANNEL_ID, - isVoiceBased: () => true, -}) - -const makeQueue = () => ({ - connection: null, - connect: jest.fn<() => Promise>().mockResolvedValue(undefined as void), -}) - -const makePlayer = (queue: ReturnType) => ({ - nodes: { - create: jest.fn().mockReturnValue(queue), - }, -}) - -const makeClient = (overrides: Record = {}) => { - const queue = makeQueue() - const player = makePlayer(queue) - const voiceChannel = makeVoiceChannel() - const guild = { - id: GUILD_ID, - channels: { cache: { get: jest.fn().mockReturnValue(voiceChannel) } }, - } - return { - guilds: { cache: { get: jest.fn().mockReturnValue(guild) } }, - player, - _queue: queue, - _guild: guild, - ...overrides, - } -} - -describe('restoreSessionsOnStartup', () => { - beforeEach(() => { - jest.clearAllMocks() - keysMock.mockResolvedValue([`music:session:${GUILD_ID}`]) - getSnapshotMock.mockResolvedValue(makeSnapshot()) - deleteSnapshotMock.mockResolvedValue(undefined) - restoreSnapshotMock.mockResolvedValue({ restoredCount: 1, sessionSnapshotId: 'snap-1' }) - }) - - it('returns early when SESSION_RESTORE_ENABLED is false', async () => { - const { ENVIRONMENT_CONFIG } = await import('@lucky/shared/config') - const original = ENVIRONMENT_CONFIG.MUSIC.SESSION_RESTORE_ENABLED - ;(ENVIRONMENT_CONFIG.MUSIC as Record).SESSION_RESTORE_ENABLED = false - try { - const client = makeClient() - await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient) - expect(keysMock).not.toHaveBeenCalled() - } finally { - ;(ENVIRONMENT_CONFIG.MUSIC as Record).SESSION_RESTORE_ENABLED = original - } - }) - - it('returns early when no Redis session keys exist', async () => { - keysMock.mockResolvedValue([]) - const client = makeClient() - await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient) - expect(getSnapshotMock).not.toHaveBeenCalled() - }) - - it('skips guild not in cache', async () => { - const client = makeClient() - ;(client.guilds.cache.get as jest.Mock).mockReturnValue(undefined) - await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient) - expect(getSnapshotMock).not.toHaveBeenCalled() - }) - - it('skips snapshot with missing voiceChannelId', async () => { - getSnapshotMock.mockResolvedValue(makeSnapshot({ voiceChannelId: undefined })) - const client = makeClient() - await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient) - expect(client.player.nodes.create).not.toHaveBeenCalled() - }) - - it('deletes stale snapshot and skips restore', async () => { - getSnapshotMock.mockResolvedValue(makeSnapshot({ savedAt: STALE_SAVED_AT })) - const client = makeClient() - await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient) - expect(deleteSnapshotMock).toHaveBeenCalledWith(GUILD_ID) - expect(client.player.nodes.create).not.toHaveBeenCalled() - }) - - it('skips when voice channel is not found or not voice-based', async () => { - const client = makeClient() - ;(client._guild.channels.cache.get as jest.Mock).mockReturnValue({ - id: VOICE_CHANNEL_ID, - isVoiceBased: () => false, - }) - await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient) - expect(client.player.nodes.create).not.toHaveBeenCalled() - }) - - it('creates queue, connects to voice, and restores snapshot for valid entry', async () => { - const client = makeClient() - await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient) - expect(client.player.nodes.create).toHaveBeenCalledWith( - client._guild, - expect.objectContaining({ metadata: expect.anything() }), - ) - expect(client._queue.connect).toHaveBeenCalled() - expect(restoreSnapshotMock).toHaveBeenCalledWith( - client._queue, - undefined, - { maxAgeMs: 30 * 60 * 1_000 }, - ) - }) - - it('isolates per-guild errors and continues sweep', async () => { - keysMock.mockResolvedValue([ - `music:session:guild-fail`, - `music:session:${GUILD_ID}`, - ]) - const client = makeClient() - const originalGet = (client.guilds.cache.get as jest.Mock).getMockImplementation() - ;(client.guilds.cache.get as jest.Mock).mockImplementation((id: string) => { - if (id === 'guild-fail') throw new Error('boom') - return originalGet ? originalGet(id) : undefined - }) - await expect( - restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient) - ).resolves.not.toThrow() - expect(restoreSnapshotMock).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/bot/src/utils/music/watchdog.rejoin.spec.ts b/packages/bot/src/utils/music/watchdog.rejoin.spec.ts deleted file mode 100644 index 42a9b8ccb..000000000 --- a/packages/bot/src/utils/music/watchdog.rejoin.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals' -import type { GuildQueue } from 'discord-player' -import { MusicWatchdogService } from './watchdog' - -jest.mock('@lucky/shared/utils', () => ({ - debugLog: jest.fn(), - errorLog: jest.fn(), - infoLog: jest.fn(), -})) - -jest.mock('@lucky/shared/services', () => ({ - redisClient: { - isHealthy: jest.fn(() => false), - keys: jest.fn(async () => []), - }, -})) - -jest.mock('./sessionSnapshots', () => ({ - musicSessionSnapshotService: { - getSnapshot: jest.fn(async () => null), - restoreSnapshot: jest.fn(async () => ({ - restoredCount: 0, - sessionSnapshotId: 'none', - })), - deleteSnapshot: jest.fn(async () => undefined), - }, -})) - -describe('MusicWatchdogService retry reconnect', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('recovers when second rejoin reaches ready state', async () => { - const connection = { - state: { status: 'disconnected' }, - rejoin: jest.fn(() => { - if (connection.rejoin.mock.calls.length >= 2) { - connection.state.status = 'ready' - } - }), - } - const play = jest.fn(async () => undefined) - const service = new MusicWatchdogService({ - recoveryWaitTimeoutMs: 0, - recoveryPollIntervalMs: 1, - }) - const queue = { - guild: { id: 'guild-rejoin-success' }, - currentTrack: { title: 'Song', url: 'https://example.com/song' }, - connection, - node: { - isPlaying: () => false, - play, - }, - tracks: { size: 0 }, - } as unknown as GuildQueue - - const action = await service.checkAndRecover(queue) - - expect(action).toBe('requeue_current') - expect(connection.rejoin).toHaveBeenCalledTimes(2) - expect(play).toHaveBeenCalledTimes(1) - }) - - it('fails with retry detail when connection stays not-ready', async () => { - const connection = { - state: { status: 'disconnected' }, - rejoin: jest.fn(), - } - const play = jest.fn(async () => undefined) - const service = new MusicWatchdogService({ - recoveryWaitTimeoutMs: 0, - recoveryPollIntervalMs: 1, - }) - const queue = { - guild: { id: 'guild-rejoin-failed' }, - currentTrack: { title: 'Song', url: 'https://example.com/song' }, - connection, - node: { - isPlaying: () => false, - play, - }, - tracks: { size: 0 }, - } as unknown as GuildQueue - - const action = await service.checkAndRecover(queue) - - expect(action).toBe('failed') - expect(connection.rejoin).toHaveBeenCalledTimes(2) - expect(play).not.toHaveBeenCalled() - expect(service.getGuildState('guild-rejoin-failed')).toEqual( - expect.objectContaining({ - lastRecoveryDetail: 'connection_not_ready_after_rejoin_retry', - }), - ) - }) -}) diff --git a/packages/bot/src/utils/music/watchdog.spec.ts b/packages/bot/src/utils/music/watchdog.spec.ts index b3a91f078..25fe14ca6 100644 --- a/packages/bot/src/utils/music/watchdog.spec.ts +++ b/packages/bot/src/utils/music/watchdog.spec.ts @@ -125,55 +125,6 @@ describe('MusicWatchdogService — orphan session monitor', () => { service.stopOrphanSessionMonitor() }) - it('startOrphanSessionMonitor is idempotent — second call is a no-op', () => { - const service = new MusicWatchdogService() - const scanSpy = jest - .spyOn(service, 'scanOrphanSessions') - .mockResolvedValue(undefined) - - const player = {} as unknown as Player - service.startOrphanSessionMonitor(player, 60_000) - service.startOrphanSessionMonitor(player, 60_000) - - jest.advanceTimersByTime(60_000) - expect(scanSpy).toHaveBeenCalledTimes(1) - - service.stopOrphanSessionMonitor() - }) - - it('stopOrphanSessionMonitor stops the interval', async () => { - const service = new MusicWatchdogService() - const scanSpy = jest - .spyOn(service, 'scanOrphanSessions') - .mockResolvedValue(undefined) - - const player = {} as unknown as Player - service.startOrphanSessionMonitor(player, 60_000) - service.stopOrphanSessionMonitor() - - await jest.advanceTimersByTimeAsync(120_000) - expect(scanSpy).not.toHaveBeenCalled() - }) - - it('scanOrphanSessions skips when Redis is unhealthy', async () => { - isHealthyMock.mockReturnValue(false) - const service = new MusicWatchdogService() - const player = {} as unknown as Player - - await service.scanOrphanSessions(player) - - expect(keysMock).not.toHaveBeenCalled() - }) - - it('scanOrphanSessions skips when no session keys exist', async () => { - keysMock.mockResolvedValue([]) - const service = new MusicWatchdogService() - const player = {} as unknown as Player - - await service.scanOrphanSessions(player) - - expect(getSnapshotMock).not.toHaveBeenCalled() - }) it('scanOrphanSessions skips guild when snapshot is missing', async () => { keysMock.mockResolvedValue(['music:session:guild-99']) @@ -557,33 +508,6 @@ describe('MusicWatchdogService — startPeriodicScan', () => { keysMock.mockResolvedValue([]) }) - it('startPeriodicScan is idempotent — second call is a no-op', async () => { - const service = new MusicWatchdogService({ scanIntervalMs: 60_000 }) - const scanSpy = jest - .spyOn(service, 'scanOrphanedSessions') - .mockResolvedValue([]) - - const getQueue = jest.fn().mockReturnValue(null) - service.startPeriodicScan(getQueue) - service.startPeriodicScan(getQueue) - - await jest.advanceTimersByTimeAsync(60_000) - expect(scanSpy).toHaveBeenCalledTimes(1) - - service.stopPeriodicScan() - }) - - it('scanOrphanedSessions does not crash on malformed Redis key', async () => { - isHealthyMock.mockReturnValue(true) - keysMock.mockResolvedValue(['music:session:']) - - const service = new MusicWatchdogService() - const getQueue = jest.fn().mockReturnValue(null) - - await expect( - service.scanOrphanedSessions(getQueue), - ).resolves.toBeDefined() - }) it('scanOrphanedSessions arms orphaned queues that are not playing', async () => { isHealthyMock.mockReturnValue(true) diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 2c53852e8..477b76e45 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,7 +1,7 @@ { "name": "lucky-webapp", "private": true, - "version": "2.9.0", + "version": "2.10.0", "type": "module", "scripts": { "dev": "vite", @@ -63,16 +63,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", @@ -81,11 +81,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" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 878bb2b1c..83846c48d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@lucky/shared", - "version": "2.9.0", + "version": "2.10.0", "description": "Shared code for Lucky modular monolith", "type": "module", "main": "./dist/index.js", @@ -75,8 +75,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" } diff --git a/packages/shared/src/utils/errorHandler.spec.ts b/packages/shared/src/utils/errorHandler.spec.ts deleted file mode 100644 index a9edce1db..000000000 --- a/packages/shared/src/utils/errorHandler.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - jest, -} from '@jest/globals' - -const errorLogMock = jest.fn() -const infoLogMock = jest.fn() -const clearAllTimersMock = jest.fn() -const flushSentryMock = jest.fn<(timeout?: number) => Promise>() -const handleErrorMock = jest.fn() -const createCorrelationIdMock = jest.fn(() => 'corr-123') - -jest.mock('./general/log', () => ({ - errorLog: errorLogMock, - infoLog: infoLogMock, -})) - -jest.mock('./timerManager', () => ({ - clearAllTimers: clearAllTimersMock, -})) - -jest.mock('./monitoring/sentry', () => ({ - flushSentry: flushSentryMock, -})) - -jest.mock('./error/errorHandler', () => ({ - handleError: handleErrorMock, - createCorrelationId: createCorrelationIdMock, -})) - -import { setupErrorHandlers } from './errorHandler' - -describe('setupErrorHandlers', () => { - const originalProcessOn = process.on - const originalProcessExit = process.exit - const handlers = new Map void>() - - beforeEach(() => { - jest.clearAllMocks() - handlers.clear() - - flushSentryMock.mockResolvedValue(true) - handleErrorMock.mockImplementation(() => ({ - metadata: { correlationId: 'corr-123' }, - })) - - process.on = jest.fn( - (event: string, handler: (...args: unknown[]) => void) => { - handlers.set(event, handler) - return process - }, - ) as typeof process.on - - process.exit = jest.fn() as unknown as typeof process.exit - }) - - afterEach(() => { - process.on = originalProcessOn - process.exit = originalProcessExit - }) - - it('flushes sentry before exiting on uncaught exceptions', async () => { - setupErrorHandlers() - - handlers.get('uncaughtException')?.(new Error('boom')) - await new Promise((resolve) => setImmediate(resolve)) - - expect(handleErrorMock).toHaveBeenCalled() - expect(errorLogMock).toHaveBeenCalled() - expect(clearAllTimersMock).toHaveBeenCalled() - expect(flushSentryMock).toHaveBeenCalledWith(3000) - expect(process.exit).toHaveBeenCalledWith(1) - }) - - it('flushes sentry before exiting on graceful shutdown signals', async () => { - setupErrorHandlers() - - handlers.get('SIGTERM')?.() - await new Promise((resolve) => setImmediate(resolve)) - - expect(infoLogMock).toHaveBeenCalled() - expect(clearAllTimersMock).toHaveBeenCalled() - expect(flushSentryMock).toHaveBeenCalledWith(3000) - expect(process.exit).toHaveBeenCalledWith(0) - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 723510c70..3ecfad937 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,23 +34,23 @@ importers: version: 2.0.0 devDependencies: '@commitlint/cli': - specifier: ^20.5.3 - version: 20.5.3(@types/node@25.6.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3) + specifier: ^21.0.0 + version: 21.0.0(@types/node@25.6.2)(conventional-commits-parser@6.4.0)(typescript@6.0.3) '@commitlint/config-conventional': - specifier: ^20.5.3 - version: 20.5.3 + specifier: ^21.0.0 + version: 21.0.0 '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.2.0(jiti@2.6.1)) '@secretlint/secretlint-rule-preset-recommend': - specifier: ^12.3.1 - version: 12.3.1 + specifier: ^13.0.0 + version: 13.0.0 '@types/jest': specifier: ^30.0.0 version: 30.0.0 '@typescript-eslint/eslint-plugin': - specifier: ^8.59.1 - version: 8.59.1(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) + specifier: ^8.59.2 + version: 8.59.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/parser': specifier: ^8.54.0 version: 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) @@ -61,8 +61,8 @@ importers: specifier: ^17.6.0 version: 17.6.0 jest: - specifier: ^30.2.0 - version: 30.3.0(@types/node@25.6.0) + specifier: ^30.4.2 + version: 30.4.2(@types/node@25.6.2) prettier: specifier: ^3.8.3 version: 3.8.3 @@ -70,11 +70,11 @@ importers: specifier: ^7.8.0 version: 7.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) secretlint: - specifier: ^12.3.1 - version: 12.3.1 + specifier: ^13.0.0 + version: 13.0.0 ts-jest: specifier: ^29.4.9 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.6.0))(typescript@6.0.3) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.6.2))(typescript@6.0.3) unfetch: specifier: ^5.0.0 version: 5.0.0 @@ -106,8 +106,8 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} '@babel/core@7.29.0': @@ -156,8 +156,8 @@ packages: resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true @@ -274,74 +274,74 @@ packages: '@bufbuild/protobuf@2.11.0': resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} - '@commitlint/cli@20.5.3': - resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} - engines: {node: '>=v18'} + '@commitlint/cli@21.0.0': + resolution: {integrity: sha512-p3y2oC0G2R45zaadMwBxCiSesS8digi5RDplP3Zrfpzm7xIgrgAj0W4fGzONjpHyg8obDVJDU45g5txzeMcblg==} + engines: {node: '>=22.12.0'} hasBin: true - '@commitlint/config-conventional@20.5.3': - resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} - engines: {node: '>=v18'} + '@commitlint/config-conventional@21.0.0': + resolution: {integrity: sha512-QJX/rPK4Yu3f5J4OCIBy5aXq2e0EEdwSDFZ3NQvFAXTm3gs12ipyZ+yjhZxm3hHn6DB8wuv3zhFTL1I2tYzUBA==} + engines: {node: '>=22.12.0'} - '@commitlint/config-validator@20.5.0': - resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} - engines: {node: '>=v18'} + '@commitlint/config-validator@21.0.0': + resolution: {integrity: sha512-v0UplTYryNUB463X5WrelzKq5/qyYm9/iUNk38S7ZLnd56Uuk2T9awhYKGlgD2/4L5YuN2gsKkyy4EHpRPPz2Q==} + engines: {node: '>=22.12.0'} - '@commitlint/ensure@20.5.3': - resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} - engines: {node: '>=v18'} + '@commitlint/ensure@21.0.0': + resolution: {integrity: sha512-n+OYs0Ws9GKC2WlmAeLNoPz9CUg6n/ZyYMkFF8rJ0aMn2kDTDTG0VqK/2Dco0EB4fhuF3JPIllJmU9/LKTl4aw==} + engines: {node: '>=22.12.0'} - '@commitlint/execute-rule@20.0.0': - resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} - engines: {node: '>=v18'} + '@commitlint/execute-rule@21.0.0': + resolution: {integrity: sha512-3OhTq2gQX1tEheMsbDNqxfcNHsAM6g9cub9plf05I9jCxtbNfn8Y+mhClKyUwhX4dbtmC4OLZ9i+HNmoL1aksA==} + engines: {node: '>=22.12.0'} - '@commitlint/format@20.5.0': - resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} - engines: {node: '>=v18'} + '@commitlint/format@21.0.0': + resolution: {integrity: sha512-RTfGSrueEgofs1piqwi42U05d85wfxiMH2ncMCZnltx1XqPR3N2S48oACBtTy4xRAhWlf5XlHkK2RaDzEQu3dA==} + engines: {node: '>=22.12.0'} - '@commitlint/is-ignored@20.5.0': - resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} - engines: {node: '>=v18'} + '@commitlint/is-ignored@21.0.0': + resolution: {integrity: sha512-K3SaaOTVY9VKhge7vl0R3ng7GENRzJQ9MPV43Tu53kAwEgSx/E0HF4US3AcVqdvlvsDUbF2yXvED95dhela83w==} + engines: {node: '>=22.12.0'} - '@commitlint/lint@20.5.3': - resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} - engines: {node: '>=v18'} + '@commitlint/lint@21.0.0': + resolution: {integrity: sha512-dlUJA0Ka14R1YaR46JVRWE3m/8dOQAgE/D0heUfzYua5Jogtq/zzu2ITAIaB/u25DaKjtEO6kuvASzsFDyrPMw==} + engines: {node: '>=22.12.0'} - '@commitlint/load@20.5.3': - resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} - engines: {node: '>=v18'} + '@commitlint/load@21.0.0': + resolution: {integrity: sha512-l0nBfO/20PKcJXHZqDIgh7kw/TWVVwn8zZJOkVGBK/ig/h328jBu9jK7OiDl2oZr5mLphmKGjYDR2ffEyb2lIA==} + engines: {node: '>=22.12.0'} - '@commitlint/message@20.4.3': - resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} - engines: {node: '>=v18'} + '@commitlint/message@21.0.0': + resolution: {integrity: sha512-+daU92JaOHhI2En9KcH+2mvZGJ6D4YSxb/32QDwqkOwSj1Vanjio8PbAqX7dneACdg6B7RgQ7i3mpyYZAws4nw==} + engines: {node: '>=22.12.0'} - '@commitlint/parse@20.5.0': - resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} - engines: {node: '>=v18'} + '@commitlint/parse@21.0.0': + resolution: {integrity: sha512-1dbvFBcQK79aTbpc2QCrgEDc6/MMkQ0Mdz4gGmYkN4AHMnAK9HesSewTHqGTrW5mALrMlYSgcWyvKjloY2w19A==} + engines: {node: '>=22.12.0'} - '@commitlint/read@20.5.0': - resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} - engines: {node: '>=v18'} + '@commitlint/read@21.0.0': + resolution: {integrity: sha512-8VKLKLl2vBSKoTMm1LwcySsyxrBeotnqcT5qJi9pPuPfqSapdAD870Ckgh79c41UFywL6kMqtiyY+kxtfcqZGg==} + engines: {node: '>=22.12.0'} - '@commitlint/resolve-extends@20.5.3': - resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} - engines: {node: '>=v18'} + '@commitlint/resolve-extends@21.0.0': + resolution: {integrity: sha512-hrJYSZRpmecmSoxYrpuJ/1Q4J9JHt4AVVtr5/Ac6upLO/jJ1DnIm2AjD+38gru3KGOec4aHCVqETuWWLJhydWw==} + engines: {node: '>=22.12.0'} - '@commitlint/rules@20.5.3': - resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} - engines: {node: '>=v18'} + '@commitlint/rules@21.0.0': + resolution: {integrity: sha512-NgQhX1qENA+rbrMw5KKyvVZpZG4D/0wgK8Z4INtcwKbfKtVDFMbn0oNc/Rs8wdyBPBj7ue8Lo/GllUL2Mqjwkg==} + engines: {node: '>=22.12.0'} - '@commitlint/to-lines@20.0.0': - resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} - engines: {node: '>=v18'} + '@commitlint/to-lines@21.0.0': + resolution: {integrity: sha512-qMwvrJK/x3dPcXsIAtQAMKV5Q0wTioyqyHKR06vVN4wmBF4cCrrLq5x81FDeY3Ba+GWgDt0/P3Zw/IHGM8lwgg==} + engines: {node: '>=22.12.0'} - '@commitlint/top-level@20.4.3': - resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} - engines: {node: '>=v18'} + '@commitlint/top-level@21.0.0': + resolution: {integrity: sha512-8jPqyWZueuN4hU6/ArKVsZ6i8xWtjIrbzHEOaLaTGUfjhhbZNBfXef/DGjzxy55hAv3yFNxHLINfI1bCJ0/MzA==} + engines: {node: '>=22.12.0'} - '@commitlint/types@20.5.0': - resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} - engines: {node: '>=v18'} + '@commitlint/types@21.0.0': + resolution: {integrity: sha512-6nEz+M7I90iix4sviA8NLwskOuyt0M98KUU2aYgiKbn46jMSxUm1l2ACtzRd9ec+y38aKyJhW4Fp6NW0z35kJQ==} + engines: {node: '>=22.12.0'} '@conventional-changelog/git-client@2.7.0': resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} @@ -503,12 +503,12 @@ packages: resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} - '@jest/console@30.3.0': - resolution: {integrity: sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==} + '@jest/console@30.4.1': + resolution: {integrity: sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/core@30.3.0': - resolution: {integrity: sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==} + '@jest/core@30.4.2': + resolution: {integrity: sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -520,36 +520,48 @@ packages: resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/environment@30.3.0': - resolution: {integrity: sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==} + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.4.1': + resolution: {integrity: sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/expect-utils@30.3.0': resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect@30.3.0': - resolution: {integrity: sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==} + '@jest/expect-utils@30.4.1': + resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/fake-timers@30.3.0': - resolution: {integrity: sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==} + '@jest/expect@30.4.1': + resolution: {integrity: sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.4.1': + resolution: {integrity: sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/get-type@30.1.0': resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/globals@30.3.0': - resolution: {integrity: sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==} + '@jest/globals@30.4.1': + resolution: {integrity: sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/pattern@30.0.1': resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/reporters@30.3.0': - resolution: {integrity: sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==} + '@jest/pattern@30.4.0': + resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.4.1': + resolution: {integrity: sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -561,30 +573,38 @@ packages: resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/snapshot-utils@30.3.0': - resolution: {integrity: sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==} + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.4.1': + resolution: {integrity: sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/source-map@30.0.1': resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-result@30.3.0': - resolution: {integrity: sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==} + '@jest/test-result@30.4.1': + resolution: {integrity: sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-sequencer@30.3.0': - resolution: {integrity: sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==} + '@jest/test-sequencer@30.4.1': + resolution: {integrity: sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/transform@30.3.0': - resolution: {integrity: sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==} + '@jest/transform@30.4.1': + resolution: {integrity: sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/types@30.3.0': resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@30.4.1': + resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -683,18 +703,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -840,42 +848,46 @@ packages: '@types/react': optional: true - '@secretlint/config-creator@12.3.1': - resolution: {integrity: sha512-CCRvPfrQLt2fPg3eWTIDGXNcVFQd6ZnvQCZ5lzclV9OF7iRqXQ4l5lfGO8NS68tIZx7YvBKhcO8/eVxdqm89HA==} + '@secretlint/config-creator@13.0.0': + resolution: {integrity: sha512-HJLoVUqXPSxu1s7b2TSjCIeScOHxD4hvPYysJUOqnKqEs/ZqXwC9uFpRo8qS79cVwe8s4lF+OoLmXnMCyMMnxQ==} engines: {node: '>=22.0.0'} - '@secretlint/config-loader@12.3.1': - resolution: {integrity: sha512-PNrxz8tnAU/y5PmfOtKfVb+zEA3I+1iZqP1f9fXvIBtauBKs0h0Y+Cmvj0gG1a34kxaD1aQvFh8qHEhRV05gWg==} + '@secretlint/config-loader@13.0.0': + resolution: {integrity: sha512-fWRE9aMZ4AHzDNaJNKX1P0W1IS08fFLe0c17jKq8BYhrlCehl2II6HqQXmef5no92SBpAEMLuL0uVAdfrtdigQ==} engines: {node: '>=22.0.0'} - '@secretlint/core@12.3.1': - resolution: {integrity: sha512-ulcfARo1TANr8tWzDO/5cFxSNEEfRzgW6YPHYUijgpH3iYfwtUhWEU/r/BiFGl2PNaGzzVE1N9A6nZ74xbYvUQ==} + '@secretlint/core@13.0.0': + resolution: {integrity: sha512-j7Yt1o7sgFsN19N0Q4CGmLOdvf0PG3DE5xMYAoQ42i+h4/7Ui3HfCKcDAycDp4aXDiJAJvluko64GY0yDobAVg==} engines: {node: '>=22.0.0'} - '@secretlint/formatter@12.3.1': - resolution: {integrity: sha512-SXpTiRzuuFNbHa59zk0eUxFOB/LYxnuHSfqq7zU9lIt0z5rox6NrnN9WWkoQai2V9s7n3VqUVUpZqnhxQ2Jzpw==} + '@secretlint/formatter@13.0.0': + resolution: {integrity: sha512-OOskMV3r7yJQFo+0PvrRalRYKwMa2kdx9hrMiO9RYshPGn+155084j0uIziburXhdQmRhhrCe+KFln87KHm5pQ==} engines: {node: '>=22.0.0'} - '@secretlint/node@12.3.1': - resolution: {integrity: sha512-1T08nqwWIJqSRrfkebk4Op5MwYgNnB6gwjv9v+X+V+HEIeG1GB/EgH8CJa8jK4uYdhUuaKyXpu36FIbjNa1wqA==} + '@secretlint/node@13.0.0': + resolution: {integrity: sha512-256dKkIHB4cXXBT9Cr/tcwf1n3IxCo5sk8Q9z4QXDux+IAUKjRxyk3c02LcJXgTc3GTrZGh+F1s17vUo3qJG+g==} engines: {node: '>=22.0.0'} - '@secretlint/profiler@12.3.1': - resolution: {integrity: sha512-lztyqJPTfkY0Ze9P7vNs3zm7p2Wq1+4ilFXVrxin0sDyFVXpkt+0+vsKsmdx9yBHabxrLDZgxa7fIsfV721cLw==} + '@secretlint/profiler@13.0.0': + resolution: {integrity: sha512-jQb/UBs0kgbzGGeQ+i4LuSSHvPwscp5Ge9UC4/izxNi3guwBTkE/bsREvbEaGR80qtn8fmWZytP88oWYMMrN2g==} - '@secretlint/resolver@12.3.1': - resolution: {integrity: sha512-/QwcX5azKRdz9mBIbTBUsqp+cmWQZYGNdOHLbsMOBTLXa7KoEBffhmeaMSc0kNSrdgbgfu/7j+qeeaF4QwJf3A==} + '@secretlint/resolver@13.0.0': + resolution: {integrity: sha512-5mUTxb+CPl/589Efq5L4SBqJ2yDguKNVZ7udCPiQasI9jS0h1P/CBWfqp+eoKlSoEggu2RKbMHdRiidRfnRXQQ==} + + '@secretlint/secretlint-rule-preset-recommend@13.0.0': + resolution: {integrity: sha512-aKjoMnyMmQ1wDxLPUH0IdnM7yPOwSopT4ZhWoctthAW7nfYQoLmHKiTI8bUKOGU9n4VfVPG8N84vXmT3sG7d/g==} + engines: {node: '>=22.0.0'} - '@secretlint/secretlint-rule-preset-recommend@12.3.1': - resolution: {integrity: sha512-w9x9rIP1+qhV0k9k15aSIBo+6NrM4npAYIoNs7UmKIxuQSA6b91rhWpdMxgCv2/EQtym+gpuyN7rTSvG5wlYlw==} + '@secretlint/source-creator@13.0.0': + resolution: {integrity: sha512-+whDtfudstqU0/rt787EDdqvNCna/c2REw9BOHYAcylZ2tpyL35gi0z7byUcqu4aJcI1z5epG7APcM0nd50PGg==} engines: {node: '>=22.0.0'} - '@secretlint/source-creator@12.3.1': - resolution: {integrity: sha512-RCkmyKdoe6VFWMzzVm5a9W+a+ptJSusVX+YOrcNy/heklMIWLg0bL+HYFcyYCm8rU2dRq2HuSYTOamDjNs0LZg==} + '@secretlint/types@13.0.0': + resolution: {integrity: sha512-C0tVelPjw8UHNeUgAMMWq85EWEItL+evpICrOezhM+jETYUYTNdnQ8dcEOuFJrevojQBxY2ygeHPiJoqkWPmNA==} engines: {node: '>=22.0.0'} - '@secretlint/types@12.3.1': - resolution: {integrity: sha512-Qv3fKvPkzUJpS9Ps6m2EPjC0RdxS2ZZrRfZAhIdl2u0zSjgf+Z0+AaCngmHRR+3Vtbw6s2FrCf4T6mLirm8Hgg==} + '@secretlint/walker@13.0.0': + resolution: {integrity: sha512-Q7+yhgKvSUA7dS8J8asO/04JfmvUnQRW9eB9CrgTmGY7m6klAHkgAyNaBw/JAe5ISxQes379su3xxa8TS9AZ/g==} engines: {node: '>=22.0.0'} '@simple-libs/child-process-utils@1.0.2': @@ -889,15 +901,11 @@ packages: '@sinclair/typebox@0.34.49': resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} - '@sindresorhus/merge-streams@4.0.0': - resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} - engines: {node: '>=18'} - '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - '@sinonjs/fake-timers@15.3.2': - resolution: {integrity: sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==} + '@sinonjs/fake-timers@15.4.0': + resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} '@snazzah/davey-android-arm-eabi@0.1.11': resolution: {integrity: sha512-T1RYbNYKN6tLOcGIDKJd8OI6FBSEemwL7DOYdTMmhqfhhMr3YVN8WOhfoxGg63OcnpTN2e2c5tdY2bAx25RmQQ==} @@ -1010,6 +1018,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1025,8 +1036,8 @@ packages: '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -1046,6 +1057,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1064,11 +1078,11 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.59.1': - resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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' @@ -1085,8 +1099,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.1': - resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -1095,8 +1109,8 @@ packages: resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.59.1': - resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.58.2': @@ -1105,14 +1119,14 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.59.1': - resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.1': - resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1122,8 +1136,8 @@ packages: resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.59.1': - resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.58.2': @@ -1132,14 +1146,14 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.59.1': - resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.1': - resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1149,12 +1163,12 @@ packages: resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.59.1': - resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1320,8 +1334,8 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} - babel-jest@30.3.0: - resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} + babel-jest@30.4.1: + resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@babel/core': ^7.11.0 || ^8.0.0-0 @@ -1330,8 +1344,8 @@ packages: resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} engines: {node: '>=12'} - babel-plugin-jest-hoist@30.3.0: - resolution: {integrity: sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==} + babel-plugin-jest-hoist@30.4.0: + resolution: {integrity: sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} babel-preset-current-node-syntax@1.2.0: @@ -1339,8 +1353,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0 || ^8.0.0-0 - babel-preset-jest@30.3.0: - resolution: {integrity: sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==} + babel-preset-jest@30.4.0: + resolution: {integrity: sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@babel/core': ^7.11.0 || ^8.0.0-beta.1 @@ -1355,8 +1369,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.19: - resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==} + baseline-browser-mapping@2.10.28: + resolution: {integrity: sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==} engines: {node: '>=6.0.0'} hasBin: true @@ -1389,10 +1403,6 @@ packages: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1431,8 +1441,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001788: - resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1465,6 +1475,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -1626,13 +1640,16 @@ packages: effect@3.20.0: resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} - electron-to-chromium@1.5.339: - resolution: {integrity: sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==} + electron-to-chromium@1.5.353: + resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1754,6 +1771,10 @@ packages: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + expect@30.4.1: + resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -1764,21 +1785,14 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -1804,10 +1818,6 @@ packages: resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} engines: {node: '>=10'} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -1850,6 +1860,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -1870,10 +1884,6 @@ packages: engines: {node: '>=18'} hasBin: true - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1895,10 +1905,6 @@ packages: resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} engines: {node: '>=18'} - globby@16.2.0: - resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} - engines: {node: '>=20'} - googlevideo@4.0.4: resolution: {integrity: sha512-S/rfuoPBI+qXCEUPJeVhXsHoISMgVhOz8hHSpGWa0OztfHhh+g9EKaEcqAb/+ttO7meoNQNqIy9dfIpz7HPc4g==} @@ -1977,9 +1983,6 @@ packages: engines: {node: '>=8'} hasBin: true - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2018,18 +2021,10 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} - is-path-inside@4.0.0: - resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} - engines: {node: '>=12'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -2077,16 +2072,16 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-changed-files@30.3.0: - resolution: {integrity: sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==} + jest-changed-files@30.4.1: + resolution: {integrity: sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-circus@30.3.0: - resolution: {integrity: sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==} + jest-circus@30.4.2: + resolution: {integrity: sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-cli@30.3.0: - resolution: {integrity: sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==} + jest-cli@30.4.2: + resolution: {integrity: sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -2095,8 +2090,8 @@ packages: node-notifier: optional: true - jest-config@30.3.0: - resolution: {integrity: sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==} + jest-config@30.4.2: + resolution: {integrity: sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@types/node': '*' @@ -2114,38 +2109,54 @@ packages: resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-docblock@30.2.0: - resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.4.0: + resolution: {integrity: sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-each@30.3.0: - resolution: {integrity: sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==} + jest-each@30.4.1: + resolution: {integrity: sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-environment-node@30.3.0: - resolution: {integrity: sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==} + jest-environment-node@30.4.1: + resolution: {integrity: sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-haste-map@30.3.0: - resolution: {integrity: sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==} + jest-haste-map@30.4.1: + resolution: {integrity: sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-leak-detector@30.3.0: - resolution: {integrity: sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==} + jest-leak-detector@30.4.1: + resolution: {integrity: sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-matcher-utils@30.3.0: resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@30.3.0: resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@30.4.1: + resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@30.3.0: resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@30.4.1: + resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-pnp-resolver@1.2.3: resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} engines: {node: '>=6'} @@ -2159,44 +2170,52 @@ packages: resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve-dependencies@30.3.0: - resolution: {integrity: sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==} + jest-regex-util@30.4.0: + resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve@30.3.0: - resolution: {integrity: sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==} + jest-resolve-dependencies@30.4.2: + resolution: {integrity: sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runner@30.3.0: - resolution: {integrity: sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==} + jest-resolve@30.4.1: + resolution: {integrity: sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runtime@30.3.0: - resolution: {integrity: sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==} + jest-runner@30.4.2: + resolution: {integrity: sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-snapshot@30.3.0: - resolution: {integrity: sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==} + jest-runtime@30.4.2: + resolution: {integrity: sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.4.1: + resolution: {integrity: sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-util@30.3.0: resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-validate@30.3.0: - resolution: {integrity: sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==} + jest-util@30.4.1: + resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.4.1: + resolution: {integrity: sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-watcher@30.3.0: - resolution: {integrity: sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==} + jest-watcher@30.4.1: + resolution: {integrity: sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-worker@30.3.0: - resolution: {integrity: sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==} + jest-worker@30.4.1: + resolution: {integrity: sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest@30.3.0: - resolution: {integrity: sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==} + jest@30.4.2: + resolution: {integrity: sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -2298,6 +2317,10 @@ packages: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2325,18 +2348,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - meriyah@6.1.4: resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} engines: {node: '>=18.0.0'} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -2409,8 +2424,8 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} normalize-package-data@8.0.0: resolution: {integrity: sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==} @@ -2609,6 +2624,10 @@ packages: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prisma@7.8.0: resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -2639,9 +2658,6 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - rc-config-loader@4.1.4: resolution: {integrity: sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==} @@ -2656,6 +2672,9 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + react@19.2.5: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} @@ -2703,16 +2722,9 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - reverbnation-scraper@2.0.0: resolution: {integrity: sha512-t1Mew5QC9QEVEry5DXyagvci2O+TgXTGoMHbNoW5NRz6LTOzK/DLHUpnrQwloX8CVX5z1a802vwHM3YgUVOvKg==} - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -2726,8 +2738,8 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - secretlint@12.3.1: - resolution: {integrity: sha512-wv8TKCjU5hbBxo5jKEX8wIE78VAoL0Ux7pu18+TxtbICMZ2OCbu6EmO3OJLbUbyfUXSPVryNLNmGVgvwY6Z0xw==} + secretlint@13.0.0: + resolution: {integrity: sha512-bc4dzeaYMiuorNAb4cPoOX87gb+w0kiReZaUEx9iu8u0/PrkMj5QCm6pXji/NDc4Z6HUZO5ihfd0HY/4iPzskQ==} engines: {node: '>=22.0.0'} hasBin: true @@ -2740,6 +2752,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + seq-queue@0.0.5: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} @@ -2762,10 +2779,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - slash@5.1.0: - resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} - engines: {node: '>=14.16'} - slice-ansi@4.0.0: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} @@ -2834,6 +2847,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -2928,10 +2945,6 @@ packages: tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - token-types@4.2.1: resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} engines: {node: '>=10'} @@ -3115,6 +3128,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3144,10 +3161,18 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3192,7 +3217,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': dependencies: @@ -3201,7 +3226,7 @@ snapshots: '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -3216,7 +3241,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -3224,7 +3249,7 @@ snapshots: '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.2 lru-cache: 5.1.1 @@ -3261,7 +3286,7 @@ snapshots: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/parser@7.29.2': + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -3353,7 +3378,7 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': @@ -3361,7 +3386,7 @@ snapshots: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 @@ -3381,63 +3406,63 @@ snapshots: '@bufbuild/protobuf@2.11.0': {} - '@commitlint/cli@20.5.3(@types/node@25.6.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3)': + '@commitlint/cli@21.0.0(@types/node@25.6.2)(conventional-commits-parser@6.4.0)(typescript@6.0.3)': dependencies: - '@commitlint/format': 20.5.0 - '@commitlint/lint': 20.5.3 - '@commitlint/load': 20.5.3(@types/node@25.6.0)(typescript@6.0.3) - '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) - '@commitlint/types': 20.5.0 + '@commitlint/format': 21.0.0 + '@commitlint/lint': 21.0.0 + '@commitlint/load': 21.0.0(@types/node@25.6.2)(typescript@6.0.3) + '@commitlint/read': 21.0.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 21.0.0 tinyexec: 1.1.2 - yargs: 17.7.2 + yargs: 18.0.0 transitivePeerDependencies: - '@types/node' - conventional-commits-filter - conventional-commits-parser - typescript - '@commitlint/config-conventional@20.5.3': + '@commitlint/config-conventional@21.0.0': dependencies: - '@commitlint/types': 20.5.0 + '@commitlint/types': 21.0.0 conventional-changelog-conventionalcommits: 9.3.1 - '@commitlint/config-validator@20.5.0': + '@commitlint/config-validator@21.0.0': dependencies: - '@commitlint/types': 20.5.0 + '@commitlint/types': 21.0.0 ajv: 8.20.0 - '@commitlint/ensure@20.5.3': + '@commitlint/ensure@21.0.0': dependencies: - '@commitlint/types': 20.5.0 + '@commitlint/types': 21.0.0 es-toolkit: 1.46.1 - '@commitlint/execute-rule@20.0.0': {} + '@commitlint/execute-rule@21.0.0': {} - '@commitlint/format@20.5.0': + '@commitlint/format@21.0.0': dependencies: - '@commitlint/types': 20.5.0 + '@commitlint/types': 21.0.0 picocolors: 1.1.1 - '@commitlint/is-ignored@20.5.0': + '@commitlint/is-ignored@21.0.0': dependencies: - '@commitlint/types': 20.5.0 - semver: 7.7.4 + '@commitlint/types': 21.0.0 + semver: 7.8.0 - '@commitlint/lint@20.5.3': + '@commitlint/lint@21.0.0': 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 - '@commitlint/load@20.5.3(@types/node@25.6.0)(typescript@6.0.3)': + '@commitlint/load@21.0.0(@types/node@25.6.2)(typescript@6.0.3)': 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(typescript@6.0.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.6.2)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3) es-toolkit: 1.46.1 is-plain-obj: 4.1.0 picocolors: 1.1.1 @@ -3445,48 +3470,46 @@ snapshots: - '@types/node' - typescript - '@commitlint/message@20.4.3': {} + '@commitlint/message@21.0.0': {} - '@commitlint/parse@20.5.0': + '@commitlint/parse@21.0.0': dependencies: - '@commitlint/types': 20.5.0 + '@commitlint/types': 21.0.0 conventional-changelog-angular: 8.3.1 conventional-commits-parser: 6.4.0 - '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + '@commitlint/read@21.0.0(conventional-commits-parser@6.4.0)': 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.1(conventional-commits-parser@6.4.0) - minimist: 1.2.8 tinyexec: 1.1.2 transitivePeerDependencies: - conventional-commits-filter - conventional-commits-parser - '@commitlint/resolve-extends@20.5.3': + '@commitlint/resolve-extends@21.0.0': 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.1 global-directory: 5.0.0 - import-meta-resolve: 4.2.0 resolve-from: 5.0.0 - '@commitlint/rules@20.5.3': + '@commitlint/rules@21.0.0': 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 - '@commitlint/to-lines@20.0.0': {} + '@commitlint/to-lines@21.0.0': {} - '@commitlint/top-level@20.4.3': + '@commitlint/top-level@21.0.0': dependencies: escalade: 3.2.0 - '@commitlint/types@20.5.0': + '@commitlint/types@21.0.0': dependencies: conventional-commits-parser: 6.4.0 picocolors: 1.1.1 @@ -3495,7 +3518,7 @@ snapshots: dependencies: '@simple-libs/child-process-utils': 1.0.2 '@simple-libs/stream-utils': 1.2.0 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: conventional-commits-parser: 6.4.0 @@ -3635,43 +3658,44 @@ snapshots: '@istanbuljs/schema@0.1.6': {} - '@jest/console@30.3.0': + '@jest/console@30.4.1': dependencies: - '@jest/types': 30.3.0 - '@types/node': 25.6.0 + '@jest/types': 30.4.1 + '@types/node': 25.6.2 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 - '@jest/core@30.3.0': + '@jest/core@30.4.2': 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 - '@types/node': 25.6.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': 25.6.2 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 4.4.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(@types/node@25.6.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(@types/node@25.6.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 transitivePeerDependencies: - babel-plugin-macros @@ -3681,41 +3705,47 @@ snapshots: '@jest/diff-sequences@30.3.0': {} - '@jest/environment@30.3.0': + '@jest/diff-sequences@30.4.0': {} + + '@jest/environment@30.4.1': dependencies: - '@jest/fake-timers': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 25.6.0 - jest-mock: 30.3.0 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.6.2 + jest-mock: 30.4.1 '@jest/expect-utils@30.3.0': dependencies: '@jest/get-type': 30.1.0 - '@jest/expect@30.3.0': + '@jest/expect-utils@30.4.1': dependencies: - expect: 30.3.0 - jest-snapshot: 30.3.0 + '@jest/get-type': 30.1.0 + + '@jest/expect@30.4.1': + dependencies: + expect: 30.4.1 + jest-snapshot: 30.4.1 transitivePeerDependencies: - supports-color - '@jest/fake-timers@30.3.0': + '@jest/fake-timers@30.4.1': dependencies: - '@jest/types': 30.3.0 - '@sinonjs/fake-timers': 15.3.2 - '@types/node': 25.6.0 - jest-message-util: 30.3.0 - jest-mock: 30.3.0 - jest-util: 30.3.0 + '@jest/types': 30.4.1 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 25.6.2 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 '@jest/get-type@30.1.0': {} - '@jest/globals@30.3.0': + '@jest/globals@30.4.1': 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 transitivePeerDependencies: - supports-color @@ -3724,15 +3754,20 @@ snapshots: '@types/node': 25.6.0 jest-regex-util: 30.0.1 - '@jest/reporters@30.3.0': + '@jest/pattern@30.4.0': + dependencies: + '@types/node': 25.6.2 + jest-regex-util: 30.4.0 + + '@jest/reporters@30.4.1': 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.31 - '@types/node': 25.6.0 + '@types/node': 25.6.2 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit-x: 0.2.2 @@ -3743,9 +3778,9 @@ snapshots: istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - 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.3.0 @@ -3756,9 +3791,13 @@ snapshots: dependencies: '@sinclair/typebox': 0.34.49 - '@jest/snapshot-utils@30.3.0': + '@jest/schemas@30.4.1': dependencies: - '@jest/types': 30.3.0 + '@sinclair/typebox': 0.34.49 + + '@jest/snapshot-utils@30.4.1': + dependencies: + '@jest/types': 30.4.1 chalk: 4.1.2 graceful-fs: 4.2.11 natural-compare: 1.4.0 @@ -3769,33 +3808,33 @@ snapshots: callsites: 3.1.0 graceful-fs: 4.2.11 - '@jest/test-result@30.3.0': + '@jest/test-result@30.4.1': 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.3 - '@jest/test-sequencer@30.3.0': + '@jest/test-sequencer@30.4.1': 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 - '@jest/transform@30.3.0': + '@jest/transform@30.4.1': dependencies: '@babel/core': 7.29.0 - '@jest/types': 30.3.0 + '@jest/types': 30.4.1 '@jridgewell/trace-mapping': 0.3.31 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 @@ -3812,6 +3851,16 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jest/types@30.4.1': + dependencies: + '@jest/pattern': 30.4.0 + '@jest/schemas': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.6.2 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3884,7 +3933,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.2 optional: true '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -3894,18 +3943,6 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - '@pkgjs/parseargs@0.11.0': optional: true @@ -4064,34 +4101,34 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@secretlint/config-creator@12.3.1': + '@secretlint/config-creator@13.0.0': dependencies: - '@secretlint/types': 12.3.1 + '@secretlint/types': 13.0.0 - '@secretlint/config-loader@12.3.1': + '@secretlint/config-loader@13.0.0': dependencies: - '@secretlint/profiler': 12.3.1 - '@secretlint/resolver': 12.3.1 - '@secretlint/types': 12.3.1 + '@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 transitivePeerDependencies: - supports-color - '@secretlint/core@12.3.1': + '@secretlint/core@13.0.0': 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 transitivePeerDependencies: - supports-color - '@secretlint/formatter@12.3.1': + '@secretlint/formatter@13.0.0': dependencies: - '@secretlint/resolver': 12.3.1 - '@secretlint/types': 12.3.1 + '@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 @@ -4104,31 +4141,36 @@ snapshots: transitivePeerDependencies: - supports-color - '@secretlint/node@12.3.1': + '@secretlint/node@13.0.0': 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 transitivePeerDependencies: - supports-color - '@secretlint/profiler@12.3.1': {} + '@secretlint/profiler@13.0.0': {} - '@secretlint/resolver@12.3.1': {} + '@secretlint/resolver@13.0.0': {} - '@secretlint/secretlint-rule-preset-recommend@12.3.1': {} + '@secretlint/secretlint-rule-preset-recommend@13.0.0': {} - '@secretlint/source-creator@12.3.1': + '@secretlint/source-creator@13.0.0': dependencies: - '@secretlint/types': 12.3.1 + '@secretlint/types': 13.0.0 istextorbinary: 9.5.0 - '@secretlint/types@12.3.1': {} + '@secretlint/types@13.0.0': {} + + '@secretlint/walker@13.0.0': + dependencies: + ignore: 7.0.5 + picomatch: 4.0.4 '@simple-libs/child-process-utils@1.0.2': dependencies: @@ -4138,13 +4180,11 @@ snapshots: '@sinclair/typebox@0.34.49': {} - '@sindresorhus/merge-streams@4.0.0': {} - '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 - '@sinonjs/fake-timers@15.3.2': + '@sinonjs/fake-timers@15.4.0': dependencies: '@sinonjs/commons': 3.0.1 @@ -4253,9 +4293,14 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 @@ -4267,7 +4312,7 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': @@ -4276,7 +4321,7 @@ snapshots: '@types/esrecurse@4.3.1': {} - '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -4299,6 +4344,10 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + '@types/normalize-package-data@2.4.4': {} '@types/pg@8.20.0': @@ -4319,14 +4368,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.59.1 + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.2 eslint: 10.2.0(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -4356,10 +4405,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@6.0.3) - '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: @@ -4370,24 +4419,24 @@ snapshots: '@typescript-eslint/types': 8.58.2 '@typescript-eslint/visitor-keys': 8.58.2 - '@typescript-eslint/scope-manager@8.59.1': + '@typescript-eslint/scope-manager@8.59.2': 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 '@typescript-eslint/tsconfig-utils@8.58.2(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/tsconfig-utils@8.59.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.59.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.59.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3) debug: 4.4.3 eslint: 10.2.0(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -4397,7 +4446,7 @@ snapshots: '@typescript-eslint/types@8.58.2': {} - '@typescript-eslint/types@8.59.1': {} + '@typescript-eslint/types@8.59.2': {} '@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.3)': dependencies: @@ -4414,27 +4463,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.59.1(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.59.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@6.0.3) - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/visitor-keys': 8.59.1 + '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.7.4 + semver: 7.8.0 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/utils@8.59.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) eslint: 10.2.0(jiti@2.6.1) typescript: 6.0.3 transitivePeerDependencies: @@ -4445,12 +4494,12 @@ snapshots: '@typescript-eslint/types': 8.58.2 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.59.1': + '@typescript-eslint/visitor-keys@8.59.2': dependencies: - '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 - '@ungap/structured-clone@1.3.0': {} + '@ungap/structured-clone@1.3.1': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -4531,7 +4580,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4572,13 +4621,13 @@ snapshots: aws-ssl-profiles@1.1.2: {} - babel-jest@30.3.0(@babel/core@7.29.0): + babel-jest@30.4.1(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 - '@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/core@7.29.0) + babel-preset-jest: 30.4.0(@babel/core@7.29.0) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -4595,7 +4644,7 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-jest-hoist@30.3.0: + babel-plugin-jest-hoist@30.4.0: dependencies: '@types/babel__core': 7.20.5 @@ -4618,10 +4667,10 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) - babel-preset-jest@30.3.0(@babel/core@7.29.0): + babel-preset-jest@30.4.0(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 - babel-plugin-jest-hoist: 30.3.0 + babel-plugin-jest-hoist: 30.4.0 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) balanced-match@1.0.2: {} @@ -4630,7 +4679,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.19: {} + baseline-browser-mapping@2.10.28: {} better-result@2.8.2: {} @@ -4661,16 +4710,12 @@ snapshots: dependencies: balanced-match: 4.0.4 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.19 - caniuse-lite: 1.0.30001788 - electron-to-chromium: 1.5.339 - node-releases: 2.0.37 + baseline-browser-mapping: 2.10.28 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.353 + node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) bs-logger@0.2.6: @@ -4709,7 +4754,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001788: {} + caniuse-lite@1.0.30001792: {} chalk@4.1.2: dependencies: @@ -4738,6 +4783,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -4772,9 +4823,9 @@ snapshots: convert-source-map@2.0.0: {} - cosmiconfig-typescript-loader@6.3.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@25.6.2)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): dependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 cosmiconfig: 9.0.1(typescript@6.0.3) jiti: 2.6.1 typescript: 6.0.3 @@ -4889,10 +4940,12 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 - electron-to-chromium@1.5.339: {} + electron-to-chromium@1.5.353: {} emittery@0.13.1: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -4928,7 +4981,7 @@ snapshots: eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esrecurse: 4.3.0 estraverse: 5.3.0 @@ -4947,7 +5000,7 @@ snapshots: '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 debug: 4.4.3 @@ -5020,6 +5073,15 @@ snapshots: jest-mock: 30.3.0 jest-util: 30.3.0 + expect@30.4.1: + dependencies: + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + exsolve@1.0.8: {} fast-check@3.23.2: @@ -5028,23 +5090,11 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 + fast-uri@3.1.2: {} fb-watchman@2.0.2: dependencies: @@ -5069,10 +5119,6 @@ snapshots: strtok3: 6.3.0 token-types: 4.2.1 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -5112,6 +5158,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-package-type@0.1.0: {} get-port-please@3.2.0: {} @@ -5128,10 +5176,6 @@ snapshots: - conventional-commits-filter - conventional-commits-parser - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -5160,15 +5204,6 @@ snapshots: globals@17.6.0: {} - globby@16.2.0: - 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 - googlevideo@4.0.4: dependencies: '@bufbuild/protobuf': 2.11.0 @@ -5200,7 +5235,7 @@ snapshots: hosted-git-info@9.0.3: dependencies: - lru-cache: 11.3.5 + lru-cache: 11.3.6 html-encoding-sniffer@6.0.0: dependencies: @@ -5234,8 +5269,6 @@ snapshots: pkg-dir: 4.2.0 resolve-cwd: 3.0.0 - import-meta-resolve@4.2.0: {} - imurmurhash@0.1.4: {} index-to-position@1.2.0: {} @@ -5261,12 +5294,8 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-number@7.0.0: {} - is-obj@2.0.0: {} - is-path-inside@4.0.0: {} - is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} @@ -5287,10 +5316,10 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 + semver: 7.8.0 transitivePeerDependencies: - supports-color @@ -5325,31 +5354,31 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jest-changed-files@30.3.0: + jest-changed-files@30.4.1: dependencies: execa: 5.1.1 - jest-util: 30.3.0 + jest-util: 30.4.1 p-limit: 3.1.0 - jest-circus@30.3.0: + jest-circus@30.4.2: dependencies: - '@jest/environment': 30.3.0 - '@jest/expect': 30.3.0 - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 25.6.0 + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.6.2 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 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.1 slash: 3.0.0 stack-utils: 2.0.6 @@ -5357,17 +5386,17 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.3.0(@types/node@25.6.0): + jest-cli@30.4.2(@types/node@25.6.2): 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(@types/node@25.6.0) - jest-util: 30.3.0 - jest-validate: 30.3.0 + jest-config: 30.4.2(@types/node@25.6.2) + jest-util: 30.4.1 + jest-validate: 30.4.1 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' @@ -5376,33 +5405,33 @@ snapshots: - supports-color - ts-node - jest-config@30.3.0(@types/node@25.6.0): + jest-config@30.4.2(@types/node@25.6.2): dependencies: '@babel/core': 7.29.0 '@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(@babel/core@7.29.0) + '@jest/pattern': 30.4.0 + '@jest/test-sequencer': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.0) chalk: 4.1.2 ci-info: 4.4.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 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -5414,47 +5443,54 @@ snapshots: chalk: 4.1.2 pretty-format: 30.3.0 - jest-docblock@30.2.0: + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + + jest-docblock@30.4.0: dependencies: detect-newline: 3.1.0 - jest-each@30.3.0: + jest-each@30.4.1: 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 - jest-environment-node@30.3.0: + jest-environment-node@30.4.1: dependencies: - '@jest/environment': 30.3.0 - '@jest/fake-timers': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 25.6.0 - jest-mock: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.6.2 + jest-mock: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 - jest-haste-map@30.3.0: + jest-haste-map@30.4.1: dependencies: - '@jest/types': 30.3.0 - '@types/node': 25.6.0 + '@jest/types': 30.4.1 + '@types/node': 25.6.2 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.4 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 - jest-leak-detector@30.3.0: + jest-leak-detector@30.4.1: dependencies: '@jest/get-type': 30.1.0 - pretty-format: 30.3.0 + pretty-format: 30.4.1 jest-matcher-utils@30.3.0: dependencies: @@ -5463,6 +5499,13 @@ snapshots: jest-diff: 30.3.0 pretty-format: 30.3.0 + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + jest-message-util@30.3.0: dependencies: '@babel/code-frame': 7.29.0 @@ -5475,112 +5518,133 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 + jest-message-util@30.4.1: + dependencies: + '@babel/code-frame': 7.29.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.4 + pretty-format: 30.4.1 + slash: 3.0.0 + stack-utils: 2.0.6 + jest-mock@30.3.0: dependencies: '@jest/types': 30.3.0 '@types/node': 25.6.0 jest-util: 30.3.0 - jest-pnp-resolver@1.2.3(jest-resolve@30.3.0): + jest-mock@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.6.2 + jest-util: 30.4.1 + + jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): optionalDependencies: - jest-resolve: 30.3.0 + jest-resolve: 30.4.1 jest-regex-util@30.0.1: {} - jest-resolve-dependencies@30.3.0: + jest-regex-util@30.4.0: {} + + jest-resolve-dependencies@30.4.2: dependencies: - jest-regex-util: 30.0.1 - jest-snapshot: 30.3.0 + jest-regex-util: 30.4.0 + jest-snapshot: 30.4.1 transitivePeerDependencies: - supports-color - jest-resolve@30.3.0: + jest-resolve@30.4.1: dependencies: chalk: 4.1.2 graceful-fs: 4.2.11 - jest-haste-map: 30.3.0 - jest-pnp-resolver: 1.2.3(jest-resolve@30.3.0) - jest-util: 30.3.0 - jest-validate: 30.3.0 + jest-haste-map: 30.4.1 + jest-pnp-resolver: 1.2.3(jest-resolve@30.4.1) + jest-util: 30.4.1 + jest-validate: 30.4.1 slash: 3.0.0 unrs-resolver: 1.11.1 - jest-runner@30.3.0: + jest-runner@30.4.2: 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 - '@types/node': 25.6.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': 25.6.2 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 transitivePeerDependencies: - supports-color - jest-runtime@30.3.0: + jest-runtime@30.4.2: 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 - '@types/node': 25.6.0 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.6.2 chalk: 4.1.2 cjs-module-lexer: 2.2.0 collect-v8-coverage: 1.0.3 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 transitivePeerDependencies: - supports-color - jest-snapshot@30.3.0: + jest-snapshot@30.4.1: dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@babel/types': 7.29.0 - '@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(@babel/core@7.29.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 - semver: 7.7.4 + 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.8.0 synckit: 0.11.12 transitivePeerDependencies: - supports-color @@ -5594,40 +5658,49 @@ snapshots: graceful-fs: 4.2.11 picomatch: 4.0.4 - jest-validate@30.3.0: + jest-util@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.6.2 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.4 + + jest-validate@30.4.1: 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 - jest-watcher@30.3.0: + jest-watcher@30.4.1: dependencies: - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 25.6.0 + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 25.6.2 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 - jest-worker@30.3.0: + jest-worker@30.4.1: dependencies: - '@types/node': 25.6.0 - '@ungap/structured-clone': 1.3.0 - jest-util: 30.3.0 + '@types/node': 25.6.2 + '@ungap/structured-clone': 1.3.1 + jest-util: 30.4.1 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.3.0(@types/node@25.6.0): + jest@30.4.2(@types/node@25.6.2): 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(@types/node@25.6.0) + jest-cli: 30.4.2(@types/node@25.6.2) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -5725,6 +5798,8 @@ snapshots: lru-cache@11.3.5: {} + lru-cache@11.3.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5733,7 +5808,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 make-error@1.3.6: {} @@ -5747,15 +5822,8 @@ snapshots: merge-stream@2.0.0: {} - merge2@1.4.1: {} - meriyah@6.1.4: {} - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - mimic-fn@2.1.0: {} minimatch@10.2.5: @@ -5821,12 +5889,12 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.37: {} + node-releases@2.0.38: {} normalize-package-data@8.0.0: dependencies: hosted-git-info: 9.0.3 - semver: 7.7.4 + semver: 7.8.0 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -5999,6 +6067,13 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.6 + prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3): dependencies: '@prisma/config': 7.8.0 @@ -6030,8 +6105,6 @@ snapshots: pure-rand@7.0.1: {} - queue-microtask@1.2.3: {} - rc-config-loader@4.1.4: dependencies: debug: 4.4.3 @@ -6053,6 +6126,8 @@ snapshots: react-is@18.3.1: {} + react-is@19.2.6: {} + react@19.2.5: {} read-pkg@10.1.0: @@ -6093,18 +6168,12 @@ snapshots: retry@0.12.0: {} - reusify@1.1.0: {} - reverbnation-scraper@2.0.0: dependencies: node-fetch: 2.7.0 transitivePeerDependencies: - encoding - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -6115,15 +6184,15 @@ snapshots: scheduler@0.27.0: {} - secretlint@12.3.1: + secretlint@13.0.0: 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 transitivePeerDependencies: - supports-color @@ -6132,6 +6201,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.0: {} + seq-queue@0.0.5: {} shebang-command@2.0.0: @@ -6146,8 +6217,6 @@ snapshots: slash@3.0.0: {} - slash@5.1.0: {} - slice-ansi@4.0.0: dependencies: ansi-styles: 4.3.0 @@ -6217,6 +6286,12 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -6307,10 +6382,6 @@ snapshots: tmpl@1.0.5: {} - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - token-types@4.2.1: dependencies: '@tokenizer/token': 0.3.0 @@ -6330,12 +6401,12 @@ snapshots: dependencies: typescript: 6.0.3 - ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.6.0))(typescript@6.0.3): + ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.6.2))(typescript@6.0.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 30.3.0(@types/node@25.6.0) + jest: 30.4.2(@types/node@25.6.2) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -6345,10 +6416,10 @@ snapshots: yargs-parser: 21.1.1 optionalDependencies: '@babel/core': 7.29.0 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.29.0) - jest-util: 30.3.0 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.0) + jest-util: 30.4.1 tslib@2.8.1: optional: true @@ -6484,6 +6555,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@5.0.1: @@ -6503,6 +6580,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -6513,6 +6592,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yocto-queue@0.1.0: {} youtubei.js@17.0.1: diff --git a/tsconfig.json b/tsconfig.json index aa3b9d63d..d6ff84b4b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,8 +20,7 @@ "allowJs": true, "incremental": true, "tsBuildInfoFile": "./dist/.tsbuildinfo", - "assumeChangesOnlyAffectDirectDependencies": true, - "ignoreDeprecations": "6.0" + "assumeChangesOnlyAffectDirectDependencies": true }, "include": ["src/**/*", "src/register.ts"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "src/webapp/frontend.backup/**"],