diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index d3331134b..e2193014e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -5,132 +5,97 @@ on: push: branches: - "**" - paths: - - 'packages/dota/**' # Dota service and related changes - - 'packages/twitch-chat/**' # Twitch Chat service changes - - 'packages/steam/**' # Steam service changes - - 'packages/twitch-events/**' # Twitch Events service changes - - 'packages/shared-utils/**' # Shared utilities used by multiple services - - 'packages/profanity-filter/**' - - 'package.json' - - 'bun.lock' - - 'packages/Dockerfile.bun' # Shared Dockerfile for bun-based services - - 'docker-compose.yml' # Compose file changes might affect all services - - '.github/workflows/builder.yml' # The build workflow itself (affects all images) + +concurrency: + group: builder-${{ github.ref }} + cancel-in-progress: true jobs: - # Dota job remains separate because it has extra change checks - dota: + changes: runs-on: ubuntu-latest - environment: prod - permissions: - packages: write + outputs: + services: ${{ steps.select.outputs.services }} steps: - uses: actions/checkout@v6 with: - fetch-depth: 2 - - - name: Check for changes in dota service - id: dota-changes - run: | - git diff --name-only HEAD^ HEAD | grep -q "^packages/profanity-filter/" && echo "profanity_filter_changed=true" >> $GITHUB_OUTPUT || echo "profanity_filter_changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^package.json\|^bun.lock" && echo "deps_changed=true" >> $GITHUB_OUTPUT || echo "deps_changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^packages/dota/" && echo "changed=true" >> $GITHUB_OUTPUT || echo "changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^packages/shared-utils/" && echo "shared_utils_changed=true" >> $GITHUB_OUTPUT || echo "shared_utils_changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^packages/Dockerfile.bun" && echo "dockerfile_changed=true" >> $GITHUB_OUTPUT || echo "dockerfile_changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^docker-compose.yml" && echo "compose_changed=true" >> $GITHUB_OUTPUT || echo "compose_changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^.github/workflows/builder.yml" && echo "workflow_changed=true" >> $GITHUB_OUTPUT || echo "workflow_changed=false" >> $GITHUB_OUTPUT - - - name: Extract branch name - if: ${{ github.event_name == 'workflow_dispatch' || steps.dota-changes.outputs.changed == 'true' || steps.dota-changes.outputs.dockerfile_changed == 'true' || steps.dota-changes.outputs.compose_changed == 'true' || steps.dota-changes.outputs.deps_changed == 'true' || steps.dota-changes.outputs.profanity_filter_changed == 'true' || steps.dota-changes.outputs.shared_utils_changed == 'true' || steps.dota-changes.outputs.workflow_changed == 'true' }} - shell: bash - run: | - echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV - # Sanitize branch name for Docker tag (replace '/' with '-') - echo "DOCKER_TAG=$(echo ${GITHUB_REF#refs/heads/} | sed 's/\//-/g')" >> $GITHUB_ENV - # Immutable per-commit tag + OCI label metadata - echo "GIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - echo "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV + fetch-depth: 0 - - name: Set up Docker Buildx - if: ${{ github.event_name == 'workflow_dispatch' || steps.dota-changes.outputs.changed == 'true' || steps.dota-changes.outputs.dockerfile_changed == 'true' || steps.dota-changes.outputs.compose_changed == 'true' || steps.dota-changes.outputs.deps_changed == 'true' || steps.dota-changes.outputs.profanity_filter_changed == 'true' || steps.dota-changes.outputs.shared_utils_changed == 'true' || steps.dota-changes.outputs.workflow_changed == 'true' }} - uses: docker/setup-buildx-action@v4 + # Single source of truth for the build dependency graph. paths-filter + # diffs the full push range (github.event.before..after), so multi-commit + # pushes can't skip a rebuild. package.json/bun.lock are listed for every + # service since they all share the root lockfile + bun install. + - uses: dorny/paths-filter@v4.0.1 + id: filter with: - driver-opts: | - network=host - image=moby/buildkit:latest - buildkitd-flags: --debug + filters: | + dota: + - 'packages/dota/**' + - 'packages/profanity-filter/**' + - 'packages/shared-utils/**' + - 'packages/Dockerfile.bun' + - 'docker-compose.yml' + - 'package.json' + - 'bun.lock' + - '.github/workflows/builder.yml' + twitch-chat: + - 'packages/twitch-chat/**' + - 'packages/shared-utils/**' + - 'packages/Dockerfile.bun' + - 'docker-compose.yml' + - 'package.json' + - 'bun.lock' + - '.github/workflows/builder.yml' + steam: + - 'packages/steam/**' + - 'packages/shared-utils/**' + - 'packages/Dockerfile.bun' + - 'docker-compose.yml' + - 'package.json' + - 'bun.lock' + - '.github/workflows/builder.yml' + twitch-events: + - 'packages/twitch-events/**' + - 'packages/shared-utils/**' + - 'packages/Dockerfile.bun' + - 'docker-compose.yml' + - 'package.json' + - 'bun.lock' + - '.github/workflows/builder.yml' - - name: Login to ghcr - if: ${{ github.event_name == 'workflow_dispatch' || steps.dota-changes.outputs.changed == 'true' || steps.dota-changes.outputs.dockerfile_changed == 'true' || steps.dota-changes.outputs.compose_changed == 'true' || steps.dota-changes.outputs.deps_changed == 'true' || steps.dota-changes.outputs.profanity_filter_changed == 'true' || steps.dota-changes.outputs.shared_utils_changed == 'true' || steps.dota-changes.outputs.workflow_changed == 'true' }} - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GHA_PAT }} - - - name: Build and push - if: ${{ github.event_name == 'workflow_dispatch' || steps.dota-changes.outputs.changed == 'true' || steps.dota-changes.outputs.dockerfile_changed == 'true' || steps.dota-changes.outputs.compose_changed == 'true' || steps.dota-changes.outputs.deps_changed == 'true' || steps.dota-changes.outputs.profanity_filter_changed == 'true' || steps.dota-changes.outputs.shared_utils_changed == 'true' || steps.dota-changes.outputs.workflow_changed == 'true' }} - uses: docker/bake-action@v7 - with: - push: true - provenance: false - targets: dota - set: | - *.platform=linux/arm64 - dota.tags=ghcr.io/${{ github.repository_owner }}/dota:${{ env.DOCKER_TAG }} - dota.tags+=ghcr.io/${{ github.repository_owner }}/dota:sha-${{ env.GIT_SHA }} - *.labels.org.opencontainers.image.revision=${{ github.sha }} - *.labels.org.opencontainers.image.created=${{ env.BUILD_DATE }} - *.labels.org.opencontainers.image.version=${{ env.DOCKER_TAG }} - *.cache-from= - *.cache-to= + - name: Select services to build + id: select + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo 'services=["dota","twitch-chat","steam","twitch-events"]' >> "$GITHUB_OUTPUT" + else + echo 'services=${{ steps.filter.outputs.changes }}' >> "$GITHUB_OUTPUT" + fi - # Combined job for twitch-chat, steam, and twitch-events using a matrix - services: + build: + needs: changes + if: needs.changes.outputs.services != '[]' runs-on: ubuntu-latest environment: prod permissions: packages: write strategy: + fail-fast: false matrix: - include: - - service: twitch-chat - changed_path: "^packages/twitch-chat/" - dockerfile: "packages/Dockerfile.bun" - - service: steam - changed_path: "^packages/steam/" - dockerfile: "packages/Dockerfile.bun" - - service: twitch-events - changed_path: "^packages/twitch-events/" - dockerfile: "packages/Dockerfile.bun" + service: ${{ fromJSON(needs.changes.outputs.services) }} steps: - uses: actions/checkout@v6 - with: - fetch-depth: 2 - - - name: Check for changes in ${{ matrix.service }} service - id: changes - run: | - git diff --name-only HEAD^ HEAD | grep -q "${{ matrix.changed_path }}" && echo "changed=true" >> $GITHUB_OUTPUT || echo "changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^packages/shared-utils/" && echo "shared_utils_changed=true" >> $GITHUB_OUTPUT || echo "shared_utils_changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^${{ matrix.dockerfile }}" && echo "dockerfile_changed=true" >> $GITHUB_OUTPUT || echo "dockerfile_changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^docker-compose.yml" && echo "compose_changed=true" >> $GITHUB_OUTPUT || echo "compose_changed=false" >> $GITHUB_OUTPUT - git diff --name-only HEAD^ HEAD | grep -q "^.github/workflows/builder.yml" && echo "workflow_changed=true" >> $GITHUB_OUTPUT || echo "workflow_changed=false" >> $GITHUB_OUTPUT - name: Extract branch name - if: ${{ github.event_name == 'workflow_dispatch' || steps.changes.outputs.changed == 'true' || steps.changes.outputs.shared_utils_changed == 'true' || steps.changes.outputs.dockerfile_changed == 'true' || steps.changes.outputs.compose_changed == 'true' || steps.changes.outputs.workflow_changed == 'true' }} shell: bash run: | - echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> "$GITHUB_ENV" # Sanitize branch name for Docker tag (replace '/' with '-') - echo "DOCKER_TAG=$(echo ${GITHUB_REF#refs/heads/} | sed 's/\//-/g')" >> $GITHUB_ENV + echo "DOCKER_TAG=$(echo ${GITHUB_REF#refs/heads/} | sed 's/\//-/g')" >> "$GITHUB_ENV" # Immutable per-commit tag + OCI label metadata - echo "GIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - echo "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV + echo "GIT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV" + echo "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" - name: Set up Docker Buildx - if: ${{ github.event_name == 'workflow_dispatch' || steps.changes.outputs.changed == 'true' || steps.changes.outputs.shared_utils_changed == 'true' || steps.changes.outputs.dockerfile_changed == 'true' || steps.changes.outputs.compose_changed == 'true' || steps.changes.outputs.workflow_changed == 'true' }} uses: docker/setup-buildx-action@v4 with: driver-opts: | @@ -139,7 +104,6 @@ jobs: buildkitd-flags: --debug - name: Login to ghcr - if: ${{ github.event_name == 'workflow_dispatch' || steps.changes.outputs.changed == 'true' || steps.changes.outputs.shared_utils_changed == 'true' || steps.changes.outputs.dockerfile_changed == 'true' || steps.changes.outputs.compose_changed == 'true' || steps.changes.outputs.workflow_changed == 'true' }} uses: docker/login-action@v4 with: registry: ghcr.io @@ -147,7 +111,6 @@ jobs: password: ${{ secrets.GHA_PAT }} - name: Build and push - if: ${{ github.event_name == 'workflow_dispatch' || steps.changes.outputs.changed == 'true' || steps.changes.outputs.shared_utils_changed == 'true' || steps.changes.outputs.dockerfile_changed == 'true' || steps.changes.outputs.compose_changed == 'true' || steps.changes.outputs.workflow_changed == 'true' }} uses: docker/bake-action@v7 with: push: true @@ -160,5 +123,5 @@ jobs: *.labels.org.opencontainers.image.revision=${{ github.sha }} *.labels.org.opencontainers.image.created=${{ env.BUILD_DATE }} *.labels.org.opencontainers.image.version=${{ env.DOCKER_TAG }} - *.cache-from= - *.cache-to= + *.cache-from=type=gha,scope=${{ matrix.service }} + *.cache-to=type=gha,mode=max,scope=${{ matrix.service }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75fa6d328..208588662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest @@ -13,16 +17,16 @@ jobs: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.14 - name: Cache bun install - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-bun- - name: Install dependencies - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 5 max_attempts: 3 diff --git a/.github/workflows/subscription-health-check.yml b/.github/workflows/subscription-health-check.yml index 0c953cfd3..c66b6e14d 100644 --- a/.github/workflows/subscription-health-check.yml +++ b/.github/workflows/subscription-health-check.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.14 - name: Delete Steam package.json run: rm -f packages/steam/package.json diff --git a/docker-compose.yml b/docker-compose.yml index 10a2b559b..53e71ebf1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,11 +16,6 @@ services: args: - DOTABOD_ENV=${DOTABOD_ENV:-development} - BUILD_CONTEXT=packages/twitch-chat - x-bake: - cache-from: - - "ghcr.io/dotabod/cache-twitch-chat:master" - cache-to: - - "ghcr.io/dotabod/cache-twitch-chat:master" hostname: twitch-chat environment: - DATABASE_URL @@ -42,11 +37,6 @@ services: args: - DOTABOD_ENV=${DOTABOD_ENV:-development} - BUILD_CONTEXT=packages/steam - x-bake: - cache-from: - - "ghcr.io/dotabod/cache-steam:master" - cache-to: - - "ghcr.io/dotabod/cache-steam:master" hostname: steam environment: - MONGO_URL @@ -67,11 +57,6 @@ services: args: - DOTABOD_ENV=${DOTABOD_ENV:-development} - BUILD_CONTEXT=packages/twitch-events - x-bake: - cache-from: - - "ghcr.io/dotabod/cache-twitch-events:master" - cache-to: - - "ghcr.io/dotabod/cache-twitch-events:master" hostname: twitch-events environment: - DATABASE_URL @@ -104,11 +89,6 @@ services: args: - DOTABOD_ENV=${DOTABOD_ENV:-development} - BUILD_CONTEXT=packages/dota - x-bake: - cache-from: - - "ghcr.io/dotabod/cache-dota:master" - cache-to: - - "ghcr.io/dotabod/cache-dota:master" hostname: dota environment: - D2PT_TOKEN diff --git a/package.json b/package.json index 1aea93ff1..79f571bd3 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "typecheck:all": "bun run --cwd packages/shared-utils typecheck && bun run --cwd packages/profanity-filter typecheck && bun run --cwd packages/dota typecheck && bun run --cwd packages/twitch-chat typecheck && bun run --cwd packages/twitch-events typecheck && bun run --cwd packages/steam typecheck", "lint:all": "biome check .", "test": "bun run --cwd packages/dota test", - "test:all": "bun run --cwd packages/shared-utils test && bun run --cwd packages/profanity-filter test && bun run --cwd packages/dota test" + "test:all": "bun run --cwd packages/shared-utils test && bun run --cwd packages/profanity-filter test && bun run --cwd packages/dota test && bun run --cwd packages/twitch-events test && bun run --cwd packages/twitch-chat test" }, "trustedDependencies": [ "@biomejs/biome", diff --git a/packages/clip-processor-py/.gitignore b/packages/clip-processor-py/.gitignore index 0d626447c..38674de50 100644 --- a/packages/clip-processor-py/.gitignore +++ b/packages/clip-processor-py/.gitignore @@ -33,8 +33,14 @@ assets/reference.png # Virtual environment venv/ +.venv/ .env/ +# pytest / coverage +.pytest_cache/ +.coverage +htmlcov/ + # Editor files .vscode/ .idea/ diff --git a/packages/clip-processor-py/pyproject.toml b/packages/clip-processor-py/pyproject.toml new file mode 100644 index 000000000..8f106d462 --- /dev/null +++ b/packages/clip-processor-py/pyproject.toml @@ -0,0 +1,22 @@ +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q --strict-markers --import-mode=importlib" +testpaths = ["tests"] +# api_server.py imports both `dota_hero_detection` (needs src on path) and +# `src.postgresql_client` (needs the package root on path), so expose both. +pythonpath = [".", "src"] + +[dependency-groups] +dev = [ + "pytest>=8", + "pytest-cov", + "pytest-mock", + # Light runtime deps the modules import at module load time. The heavy native + # deps (opencv/numpy/streamlink/etc.) are stubbed in tests/conftest.py. + "flask", + "requests", + "psutil", + "psycopg2-binary", + "beautifulsoup4", + "Pillow", +] diff --git a/packages/clip-processor-py/requirements.txt b/packages/clip-processor-py/requirements.txt index 30fa2881f..612ddcffe 100644 --- a/packages/clip-processor-py/requirements.txt +++ b/packages/clip-processor-py/requirements.txt @@ -8,7 +8,7 @@ beautifulsoup4==4.12.2 tqdm==4.66.3 flask==3.1.3 gunicorn==23.0.0 -streamlink==6.5.0 +streamlink==8.4.0 psycopg2-binary==2.9.9 waitress==3.0.1 psutil==5.9.8 diff --git a/packages/clip-processor-py/src/api_server.py b/packages/clip-processor-py/src/api_server.py index 115682444..9b5ca2665 100644 --- a/packages/clip-processor-py/src/api_server.py +++ b/packages/clip-processor-py/src/api_server.py @@ -610,7 +610,7 @@ def extract_clip_id(clip_url): parsed = urlparse(clip_url) if parsed.path: # Get last part of path - path_parts = parsed.path.strip('/').split('/') + path_parts = [p for p in parsed.path.strip('/').split('/') if p] if path_parts: return path_parts[-1] @@ -681,6 +681,18 @@ def _normalize_name(s: str) -> str: return ''.join(ch for ch in s if ch.isalnum()) +def _normalize_tokens(s: str) -> list: + """Split a raw name into normalized word tokens (length >= 2). + + Tokenizing the *raw* name (before `_normalize_name` strips spaces) is what lets + word-swapped multi-word names ("Team Liquid" / "Liquid Team") align via token + overlap; tokenizing the already-collapsed normalized form never could. + """ + if not s: + return [] + return [t for t in (_normalize_name(w) for w in re.split(r"\s+", s.strip())) if len(t) >= 2] + + def _align_players_with_draft(players: list, draft_order: list, min_ratio: float = 0.7): """Return mapping and reordered players based on draft order names using tolerant fuzzy match. @@ -690,19 +702,14 @@ def _align_players_with_draft(players: list, draft_order: list, min_ratio: float 3) token overlap (>= 0.5) 4) difflib ratio (>= min_ratio) """ - def tokens(s: str): - # split by spaces after unicode normalization; keep tokens length>=2 - t = [t for t in re.split(r"\s+", s) if len(t) >= 2] - return t or ([s] if s else []) - current_raw = [p.get('player_name', '') or '' for p in players] draft_raw = [n or '' for n in draft_order] current_norm = [_normalize_name(x) for x in current_raw] draft_norm = [_normalize_name(x) for x in draft_raw] - current_tokens = [tokens(x) for x in current_norm] - draft_tokens = [tokens(x) for x in draft_norm] + current_tokens = [_normalize_tokens(x) for x in current_raw] + draft_tokens = [_normalize_tokens(x) for x in draft_raw] # Step 1: exact matches unmatched_current = set(range(len(players))) @@ -807,13 +814,9 @@ def _refine_alignment_with_captains_and_leftovers(mapping, players, draft_order, # 2) If leftovers remain (1-3 pairs), assign by best score without thresholds if unmatched_draft and unmatched_current: - # reuse scoring from alignment - def tokens(s: str): - t = [t for t in re.split(r"\s+", s) if len(t) >= 2] - return t or ([s] if s else []) - - draft_tokens = [tokens(x) for x in draft_norm] - current_tokens = [tokens(x) for x in current_norm] + # reuse scoring from alignment (tokenize raw names so word order can match) + draft_tokens = [_normalize_tokens(n or '') for n in draft_order] + current_tokens = [_normalize_tokens(p.get('player_name', '') or '') for p in players] candidates = [] for di in list(unmatched_draft): @@ -966,8 +969,9 @@ def process_clip_request(clip_url, clip_id, debug=False, force=False, include_im only_draft=only_draft ) - # Check if this is an existing request already in the queue - if 'status' in queue_info and queue_info.get('status') in ('pending', 'processing'): + # Only an already-queued duplicate short-circuits here; a fresh insert + # (also 'pending') must fall through to start the worker. + if queue_info.get('deduplicated'): # Create a response with queue status info response = { 'queued': True, @@ -979,10 +983,10 @@ def process_clip_request(clip_url, clip_id, debug=False, force=False, include_im 'estimated_completion_time': queue_info.get('estimated_completion_time'), } - if queue_info.get('status') == 'pending': - response['message'] = 'This clip is already in the processing queue' - elif queue_info.get('status') == 'processing': + if queue_info.get('status') == 'processing': response['message'] = 'This clip is currently being processed' + else: + response['message'] = 'This clip is already in the processing queue' return response @@ -1132,8 +1136,9 @@ def process_stream_request(username, num_frames=3, debug=False, include_image=Tr include_image=include_image ) - # Check if this is an existing request already in the queue - if 'status' in queue_info and queue_info.get('status') in ('pending', 'processing'): + # Only an already-queued duplicate short-circuits here; a fresh insert + # (also 'pending') must fall through to start the worker. + if queue_info.get('deduplicated'): # Create a response with queue status info response = { 'queued': True, @@ -1145,10 +1150,10 @@ def process_stream_request(username, num_frames=3, debug=False, include_image=Tr 'estimated_completion_time': queue_info.get('estimated_completion_time'), } - if queue_info.get('status') == 'pending': - response['message'] = 'This stream is already in the processing queue' - elif queue_info.get('status') == 'processing': + if queue_info.get('status') == 'processing': response['message'] = 'This stream is currently being processed' + else: + response['message'] = 'This stream is already in the processing queue' return response diff --git a/packages/clip-processor-py/src/postgresql_client.py b/packages/clip-processor-py/src/postgresql_client.py index 522e9e53e..0bf79410f 100644 --- a/packages/clip-processor-py/src/postgresql_client.py +++ b/packages/clip-processor-py/src/postgresql_client.py @@ -221,6 +221,37 @@ def initialize(self) -> bool: if conn: self._return_connection(conn) + @staticmethod + def _merge_facets_into_result(result: Dict[str, Any], facets: Optional[Dict[str, Any]]) -> None: + """Attach stored facet info onto a result's players/heroes in place. + + players carry 1-indexed positions; heroes are 0-indexed, hence the +1. + Rows missing team/position are skipped, and an absent team key is treated + as empty so a partial facets payload never discards the cached result. + """ + if not facets or not isinstance(result, dict): + return + + for player in result.get('players', []): + team = (player.get('team') or '').lower() + position = player.get('position') + if not team or position is None: + continue + for hero_facet in facets.get(team, []): + if hero_facet['position'] == position: + player['facet'] = hero_facet['facet'] + break + + for hero in result.get('heroes', []): + team = (hero.get('team') or '').lower() + if not team or hero.get('position') is None: + continue + position = hero['position'] + 1 + for hero_facet in facets.get(team, []): + if hero_facet['position'] == position: + hero['facet'] = hero_facet['facet'] + break + def get_clip_result(self, clip_id: str) -> Optional[Dict[str, Any]]: """ Get cached result for a clip_id if it exists. @@ -258,31 +289,7 @@ def get_clip_result(self, clip_id: str) -> Optional[Dict[str, Any]]: if row: logger.info(f"Found cached result for clip ID: {clip_id}") result = row['results'] - facets = row['facets'] - if facets: - # Add facets to players array - if 'players' in result: - for player in result['players']: - team = player['team'].lower() - position = player['position'] - - # Find matching facet info - for hero_facet in facets[team]: - if hero_facet['position'] == position: - player['facet'] = hero_facet['facet'] - break - - # Also add facets to heroes array if present - if 'heroes' in result: - for hero in result['heroes']: - team = hero['team'].lower() - position = hero['position'] + 1 # Convert to 1-indexed - - # Find matching facet info - for hero_facet in facets[team]: - if hero_facet['position'] == position: - hero['facet'] = hero_facet['facet'] - break + self._merge_facets_into_result(result, row['facets']) return result else: logger.debug(f"No cached result found for clip ID: {clip_id}") @@ -332,34 +339,11 @@ def get_clip_result_by_match_id(self, match_id: str) -> Optional[Dict[str, Any]] if row: result = row['results'] - facets = row['facets'] # Add clip details to result if isinstance(result, dict): result['clip_id'] = row['clip_id'] result['clip_url'] = row['clip_url'] - if facets: - if 'players' in result: - for player in result['players']: - team = player['team'].lower() - position = player['position'] - - # Find matching facet info - for hero_facet in facets[team]: - if hero_facet['position'] == position: - player['facet'] = hero_facet['facet'] - break - - # Also add facets to heroes array if present - if 'heroes' in result: - for hero in result['heroes']: - team = hero['team'].lower() - position = hero['position'] + 1 # Convert to 1-indexed - - # Find matching facet info - for hero_facet in facets[team]: - if hero_facet['position'] == position: - hero['facet'] = hero_facet['facet'] - break + self._merge_facets_into_result(result, row['facets']) # Also attach latest draft info if available try: draft = self.get_latest_draft_for_match(match_id) @@ -882,7 +866,7 @@ def add_to_queue(self, cursor.close() logger.info(f"Returning existing queue entry for clip ID: {clip_id}") # Let the finally block return the connection - return existing['request_id'], dict(existing) + return existing['request_id'], {**dict(existing), 'deduplicated': True} # If this is a draft-only request and we already have a draft queued/processing for the same match, reuse it if has_match_id_column and has_only_draft_column and match_id and only_draft: @@ -897,7 +881,7 @@ def add_to_queue(self, if existing_match_draft: cursor.close() logger.info(f"Found existing draft request for match {match_id} in queue ({existing_match_draft['request_id']}), returning it instead of enqueuing a duplicate") - return existing_match_draft['request_id'], dict(existing_match_draft) + return existing_match_draft['request_id'], {**dict(existing_match_draft), 'deduplicated': True} elif request_type == 'stream' and stream_username: query = f""" SELECT * FROM {self.queue_table} @@ -911,7 +895,7 @@ def add_to_queue(self, cursor.close() logger.info(f"Returning existing queue entry for stream: {stream_username}") # Let the finally block return the connection - return existing['request_id'], dict(existing) + return existing['request_id'], {**dict(existing), 'deduplicated': True} # Calculate position and estimated wait time cursor.execute(f"SELECT COUNT(*) FROM {self.queue_table} WHERE status = 'pending'") diff --git a/packages/clip-processor-py/test/test_queue.py b/packages/clip-processor-py/test/test_queue.py deleted file mode 100644 index d36b0abd6..000000000 --- a/packages/clip-processor-py/test/test_queue.py +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env python3 -""" -Unit tests for the clip processing queue system -""" - -import unittest -import time -from unittest.mock import MagicMock, patch -import sys -import os -from datetime import datetime, timedelta - -# Adjust path to import modules from parent directory -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src'))) - -# Import the modules to test -from postgresql_client import PostgresClient - -class TestQueueSystem(unittest.TestCase): - """Test cases for the queue system.""" - - def setUp(self): - """Set up test fixtures.""" - # Create a mock DB client - self.db_client = PostgresClient() - # Override connection methods to avoid actual DB connections - self.db_client._test_connection = MagicMock(return_value=True) - self.db_client._get_connection = MagicMock() - self.db_client._return_connection = MagicMock() - self.db_client._initialized = True - - def test_add_to_queue(self): - """Test adding a request to the queue.""" - # Mock the cursor and connection - mock_conn = MagicMock() - mock_cursor = MagicMock() - self.db_client._get_connection.return_value = mock_conn - mock_conn.cursor.return_value = mock_cursor - - # Set up mock response for queue length - mock_cursor.fetchone.side_effect = [ - {'count': 2}, # First call for queue length - {'id': 1, 'request_id': 'test-id', 'status': 'pending', 'position': 3} # Second call for the inserted row - ] - - # Call the method - request_id, queue_info = self.db_client.add_to_queue( - request_type='clip', - clip_id='test-clip', - clip_url='https://clips.twitch.tv/test-clip' - ) - - # Assertions - self.assertIsNotNone(request_id) - self.assertEqual(queue_info.get('status'), 'pending') - self.assertEqual(queue_info.get('position'), 3) - mock_cursor.execute.assert_called() # Should have executed SQL - mock_conn.commit.assert_called_once() # Should have committed - - def test_get_average_processing_time(self): - """Test calculating average processing time.""" - # Mock the cursor and connection - mock_conn = MagicMock() - mock_cursor = MagicMock() - self.db_client._get_connection.return_value = mock_conn - mock_conn.cursor.return_value = mock_cursor - - # Test with existing data - mock_cursor.fetchone.return_value = [10.5] # Average processing time - - avg_time = self.db_client.get_average_processing_time('clip') - self.assertEqual(avg_time, 10.5) - - # Test with no data - mock_cursor.fetchone.return_value = [None] - avg_time = self.db_client.get_average_processing_time('clip') - self.assertEqual(avg_time, 15.0) # Should return default value - - # Test for stream type - mock_cursor.fetchone.return_value = [20.3] - avg_time = self.db_client.get_average_processing_time('stream') - self.assertEqual(avg_time, 20.3) - - def test_update_queue_status(self): - """Test updating the queue status.""" - # Mock the cursor and connection - mock_conn = MagicMock() - mock_cursor = MagicMock() - self.db_client._get_connection.return_value = mock_conn - mock_conn.cursor.return_value = mock_cursor - - # Test updating to processing - success = self.db_client.update_queue_status('test-id', 'processing') - self.assertTrue(success) - mock_conn.commit.assert_called_once() - - # Reset mocks - mock_conn.reset_mock() - mock_cursor.reset_mock() - - # Test updating to completed - success = self.db_client.update_queue_status('test-id', 'completed', 'result-id') - self.assertTrue(success) - self.assertEqual(mock_cursor.execute.call_count, 2) # Should call execute twice - mock_conn.commit.assert_called_once() - - def test_get_next_pending_request(self): - """Test getting next pending request.""" - # Mock the cursor and connection - mock_conn = MagicMock() - mock_cursor = MagicMock() - self.db_client._get_connection.return_value = mock_conn - mock_conn.cursor.return_value = mock_cursor - - # Set up mock response - mock_request = { - 'request_id': 'test-id', - 'clip_id': 'test-clip', - 'status': 'pending', - 'position': 1 - } - mock_cursor.fetchone.return_value = mock_request - - # Call the method - request = self.db_client.get_next_pending_request() - - # Assertions - self.assertIsNotNone(request) - self.assertEqual(request['request_id'], 'test-id') - self.assertEqual(request['status'], 'pending') - - # Test no pending requests - mock_cursor.fetchone.return_value = None - request = self.db_client.get_next_pending_request() - self.assertIsNone(request) - - def test_queue_status_estimation(self): - """Test queue position and wait time estimation.""" - # Create a new request with position 5 - now = datetime.now() - avg_time = 15.0 # seconds per request - - # Calculate expected values - position = 5 - expected_wait = position * avg_time - expected_completion = now + timedelta(seconds=expected_wait) - - # Verify calculations manually - self.assertEqual(expected_wait, 75.0) - - # In a real implementation, this would be tested against the actual - # database calculations, but for this unit test we're just verifying logic - - def test_duplicate_request_detection(self): - """Test that duplicate requests are detected correctly.""" - # Mock the cursor and connection - mock_conn = MagicMock() - mock_cursor = MagicMock() - self.db_client._get_connection.return_value = mock_conn - mock_conn.cursor.return_value = mock_cursor - - # Set up mock response for is_request_in_queue - mock_existing_request = { - 'request_id': 'existing-id', - 'clip_id': 'test-clip', - 'status': 'pending', - 'position': 3, - 'created_at': datetime.now(), - 'estimated_wait_seconds': 45, - 'estimated_completion_time': datetime.now() + timedelta(seconds=45) - } - mock_cursor.fetchone.return_value = mock_existing_request - - # Test is_request_in_queue function - existing = self.db_client.is_request_in_queue('clip', 'test-clip') - self.assertIsNotNone(existing) - self.assertEqual(existing['request_id'], 'existing-id') - - # Test that add_to_queue returns the existing request - request_id, queue_info = self.db_client.add_to_queue( - request_type='clip', - clip_id='test-clip', - clip_url='https://clips.twitch.tv/test-clip' - ) - - # The is_request_in_queue should be called, and the existing request returned - self.assertEqual(request_id, 'existing-id') - self.assertEqual(queue_info['status'], 'pending') - self.assertEqual(queue_info['position'], 3) - - # For a stream request - mock_cursor.fetchone.return_value = { - 'request_id': 'stream-id', - 'stream_username': 'test-user', - 'status': 'processing', - 'position': 0 - } - - existing = self.db_client.is_request_in_queue('stream', None, 'test-user') - self.assertIsNotNone(existing) - self.assertEqual(existing['request_id'], 'stream-id') - - # Test with no match - mock_cursor.fetchone.return_value = None - existing = self.db_client.is_request_in_queue('clip', 'non-existent-clip') - self.assertIsNone(existing) - -if __name__ == '__main__': - unittest.main() diff --git a/packages/clip-processor-py/tests/conftest.py b/packages/clip-processor-py/tests/conftest.py new file mode 100644 index 000000000..fc4a7f7f4 --- /dev/null +++ b/packages/clip-processor-py/tests/conftest.py @@ -0,0 +1,73 @@ +"""Shared pytest fixtures and offline import shims for clip-processor-py. + +The service depends on heavy native libraries (OpenCV, NumPy, streamlink, ...) +that aren't needed to exercise its pure logic. We stub them in `sys.modules` +*before* any `src/` module is imported so the whole suite runs offline without +those wheels installed. This block runs at conftest import time, which pytest +guarantees happens before collecting/importing the test modules. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +import pytest + +# api_server.py raises at import time unless an API key is set OR we're in local +# mode. Tests never hit the network, so force local mode before any src import. +os.environ.setdefault("RUN_LOCALLY", "true") + +# Native / heavy modules replaced by mocks. The source modules only touch these +# inside image/stream code paths we don't drive directly in these tests. +_STUBBED = ("cv2", "numpy", "tqdm", "streamlink", "pytesseract", "moviepy") +for _name in _STUBBED: + sys.modules.setdefault(_name, MagicMock()) +# streamlink.stream is imported as a submodule; give it a mock too. +sys.modules.setdefault("streamlink.stream", MagicMock()) +# tqdm is imported as `from tqdm import tqdm` (a pass-through iterable wrapper). +sys.modules["tqdm"].tqdm = lambda iterable=None, *a, **k: iterable if iterable is not None else iter(()) + + +@pytest.fixture +def mock_cursor(): + """A mock DB cursor usable as a context manager and a plain object.""" + cursor = MagicMock() + cursor.__enter__ = MagicMock(return_value=cursor) + cursor.__exit__ = MagicMock(return_value=False) + return cursor + + +@pytest.fixture +def db_client(mock_cursor): + """A PostgresClient with all real connection I/O stubbed out (offline).""" + from postgresql_client import PostgresClient + + client = PostgresClient() + client._initialized = True + conn = MagicMock() + conn.cursor.return_value = mock_cursor + client._get_connection = MagicMock(return_value=conn) + client._return_connection = MagicMock() + client._test_connection = MagicMock(return_value=True) + client.initialize = MagicMock(return_value=True) + client._mock_conn = conn # exposed for assertions + return client + + +@pytest.fixture +def players(): + """Sample detected-players list as produced by hero detection.""" + return [ + {"player_name": "Miracle-", "team": "Radiant", "position": 0}, + {"player_name": "N0tail", "team": "Radiant", "position": 1}, + {"player_name": "Topson", "team": "Dire", "position": 2}, + {"player_name": "Ceb", "team": "Dire", "position": 3}, + ] + + +@pytest.fixture +def draft_order(): + """Sample draft-order name list (parallel to `players`).""" + return ["Miracle-", "N0tail", "Topson", "Ceb"] diff --git a/packages/clip-processor-py/tests/test_alignment.py b/packages/clip-processor-py/tests/test_alignment.py new file mode 100644 index 000000000..9b7679b9a --- /dev/null +++ b/packages/clip-processor-py/tests/test_alignment.py @@ -0,0 +1,214 @@ +"""Characterization tests for the draft <-> detected-players alignment. + +These pin the *observable* behavior of the fuzzy matcher before it gets +refactored, so a future scoring-unification can't silently change which player +maps to which draft slot. +""" + +import pytest + +from api_server import ( + _align_players_with_draft, + _normalize_name, + _refine_alignment_with_captains_and_leftovers, +) + + +def _p(*names): + """Build a players list (list of {'player_name': ...}) from raw names.""" + return [{"player_name": n} for n in names] + + +# --------------------------------------------------------------------------- # +# _normalize_name +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "raw, expected", + [ + ("Miracle-", "miracle"), + (" N0tail ", "n0tail"), + ("Ab Cd!", "abcd"), + ("", ""), + (None, ""), + ("Аdmiral", "admiral"), # leading char is Cyrillic 'А' (U+0410) + ("СКА", "cka"), # all-Cyrillic -> latin skeleton + ], +) +def test_normalize_name(raw, expected): + assert _normalize_name(raw) == expected + + +# --------------------------------------------------------------------------- # +# _align_players_with_draft +# --------------------------------------------------------------------------- # +def test_exact_match_same_order(): + players = _p("Miracle-", "N0tail", "Topson", "Ceb") + draft = ["Miracle-", "N0tail", "Topson", "Ceb"] + mapping, reordered = _align_players_with_draft(players, draft) + assert mapping == {0: 0, 1: 1, 2: 2, 3: 3} + assert [r["player_name"] for r in reordered] == draft + + +def test_exact_match_shuffled_order(): + players = _p("Ceb", "Topson", "N0tail", "Miracle-") + draft = ["Miracle-", "N0tail", "Topson", "Ceb"] + mapping, reordered = _align_players_with_draft(players, draft) + # draft index -> player index in the (shuffled) players list + assert mapping == {0: 3, 1: 2, 2: 1, 3: 0} + assert [r["player_name"] for r in reordered] == draft + + +def test_substring_containment_match(): + # normalized draft name is a substring of the detected name + players = _p("n0tailthebest") + draft = ["N0tail"] + mapping, reordered = _align_players_with_draft(players, draft) + assert mapping == {0: 0} + assert reordered[0]["player_name"] == "n0tailthebest" + + +def test_fuzzy_difflib_match_above_threshold(): + # single-character OCR error, difflib ratio >= default min_ratio (0.7) + players = _p("MiracIe") # capital i instead of lowercase L + draft = ["Miracle"] + mapping, _ = _align_players_with_draft(players, draft) + assert mapping == {0: 0} + + +def test_no_match_below_threshold_leaves_unmatched(): + players = _p("zzzzzz") + draft = ["Miracle"] + mapping, reordered = _align_players_with_draft(players, draft) + assert mapping == {} + assert reordered == [None] + + +def test_cyrillic_confusable_aligns_to_latin(): + players = _p("Аdmiral") # Cyrillic leading 'А' + draft = ["admiral"] + mapping, _ = _align_players_with_draft(players, draft) + assert mapping == {0: 0} + + +def test_word_swapped_multiword_names_align_via_token_overlap(): + # Regression guard for the tokenizer fix: before tokenizing the raw name, + # "Team Liquid" / "Liquid Team" collapsed to single tokens and the difflib + # ratio (~0.60) fell below min_ratio (0.7), leaving them UNMATCHED. Token + # overlap (1.0) now aligns them. + players = _p("Liquid Team") + draft = ["Team Liquid"] + mapping, reordered = _align_players_with_draft(players, draft) + assert mapping == {0: 0} + assert reordered[0]["player_name"] == "Liquid Team" + + +def test_partial_token_overlap_below_threshold_stays_unmatched(): + # Only one of two draft words appears -> overlap 0.5, below the 0.6 cutoff, + # and the difflib ratio is low, so it remains unmatched (no false positive). + players = _p("Evil Geniuses") + draft = ["Team Spirit"] + mapping, _ = _align_players_with_draft(players, draft) + assert mapping == {} + + +def test_reordered_length_matches_draft_when_counts_differ(): + players = _p("Miracle-", "N0tail") + draft = ["Miracle-", "N0tail", "Topson"] # one extra draft slot + mapping, reordered = _align_players_with_draft(players, draft) + assert len(reordered) == len(draft) + assert reordered[2] is None # unmatched draft slot stays empty + + +# --------------------------------------------------------------------------- # +# _refine_alignment_with_captains_and_leftovers +# --------------------------------------------------------------------------- # +def test_refine_anchors_captains_from_draft_info(): + # Nothing matched yet; captains in draft_info anchor draft 0/1 to exact names. + players = _p("RadiantCap", "DireCap") + draft = ["RadiantCap", "DireCap"] + draft_info = {"captains": {"Radiant": "RadiantCap", "Dire": "DireCap"}} + mapping, reordered = _refine_alignment_with_captains_and_leftovers( + {}, players, draft, draft_info=draft_info + ) + assert mapping == {0: 0, 1: 1} + assert [r["player_name"] for r in reordered] == draft + + +def test_refine_falls_back_to_strategy_captains(): + players = _p("RadiantCap", "DireCap") + draft = ["RadiantCap", "DireCap"] + mapping, _ = _refine_alignment_with_captains_and_leftovers( + {}, players, draft, draft_info={}, strategy_captains={"Radiant": "RadiantCap"} + ) + assert mapping[0] == 0 # anchored from strategy captains + + +def test_refine_assigns_leftovers_without_threshold(): + # A pair that would fall below the strict alignment threshold still gets + # assigned here because refine has no minimum score. + players = _p("zzzzzz") + draft = ["Miracle"] + mapping, reordered = _refine_alignment_with_captains_and_leftovers( + {}, players, draft, draft_info={} + ) + assert mapping == {0: 0} + assert reordered[0]["player_name"] == "zzzzzz" + + +def test_refine_preserves_existing_mapping(): + players = _p("Miracle-", "N0tail") + draft = ["Miracle-", "N0tail"] + mapping, _ = _refine_alignment_with_captains_and_leftovers( + {0: 0}, players, draft, draft_info={} + ) + assert mapping[0] == 0 + assert mapping[1] == 1 + + +def test_refine_leftover_containment_wins_over_weak_ratio(): + # "miracle" is a substring of "xmiraclex" -> containment score (1.10) beats the + # low difflib ratio of the other pairing, so it is assigned first, leaving the + # remaining draft slot for the remaining player. + players = _p("xMiraclex", "zzzz") + draft = ["Miracle", "abcd"] + mapping, reordered = _refine_alignment_with_captains_and_leftovers( + {}, players, draft, draft_info={} + ) + assert mapping == {0: 0, 1: 1} + assert reordered[0]["player_name"] == "xMiraclex" + assert reordered[1]["player_name"] == "zzzz" + + +def test_refine_assigns_all_three_leftovers(): + players = _p("Alpha", "Bravo", "Charlie") + draft = ["Charlie", "Alpha", "Bravo"] + mapping, reordered = _refine_alignment_with_captains_and_leftovers( + {}, players, draft, draft_info={} + ) + assert mapping == {0: 2, 1: 0, 2: 1} + assert [r["player_name"] for r in reordered] == draft + + +def test_refine_captain_anchor_takes_precedence_over_ambiguous_leftover(): + # Two players both *contain* the captain name "cap" (equal leftover score), + # so without anchoring the greedy tie could map draft 0 to either. The exact + # captain anchor must deterministically bind draft 0 -> the exact "Cap" player. + players = _p("Cap", "Capx") + draft = ["Cap", "Other"] + draft_info = {"captains": {"Radiant": "Cap", "Dire": None}} + mapping, reordered = _refine_alignment_with_captains_and_leftovers( + {}, players, draft, draft_info=draft_info + ) + assert mapping[0] == 0 + assert reordered[0]["player_name"] == "Cap" + + +def test_refine_word_reordered_team_names_align_via_token_overlap(): + # Tokens come from the raw name, so word-swapped multi-word names align via + # token overlap (every word of one appears in the other). + players = _p("Liquid Team") + draft = ["Team Liquid"] + mapping, _ = _refine_alignment_with_captains_and_leftovers( + {}, players, draft, draft_info={} + ) + assert mapping == {0: 0} diff --git a/packages/clip-processor-py/tests/test_api_server.py b/packages/clip-processor-py/tests/test_api_server.py index d4daacdc3..93ea3a365 100644 --- a/packages/clip-processor-py/tests/test_api_server.py +++ b/packages/clip-processor-py/tests/test_api_server.py @@ -1,178 +1,343 @@ -#!/usr/bin/env python3 -""" -Tests for the API server functionality, focusing on queue processing and frame_image_url handling +"""Characterization tests for api_server: pure helpers, the queue worker +dispatch, and the process_clip_request cache matrix. + +These pin behavior before `process_queue_worker` (145 lines) and +`process_clip_request` (243 lines) get decomposed. """ -import unittest -from unittest.mock import patch, MagicMock, ANY -import os -import sys -import json -from pathlib import Path - -# Add the src directory to the Python path -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from api_server import process_clip_request, process_queue_worker, get_image_url, parse_bool_param - - -class APIServerTests(unittest.TestCase): - """Test cases for the API server functionality""" - - def test_parse_bool_param_truthy_and_falsy(self): - """Ensure parse_bool_param handles common truthy/falsy values.""" - self.assertTrue(parse_bool_param("1", False)) - self.assertTrue(parse_bool_param("TRUE", False)) - self.assertTrue(parse_bool_param("yes", False)) - self.assertFalse(parse_bool_param("0", True)) - self.assertFalse(parse_bool_param("False", True)) - self.assertFalse(parse_bool_param("no", True)) - self.assertTrue(parse_bool_param(None, True)) - self.assertFalse(parse_bool_param("unknown", False)) - - @patch("api_server.process_clip_url") - @patch("api_server.db_client") - @patch("api_server.get_image_url") - def test_process_clip_request_from_worker(self, mock_get_image_url, mock_db_client, mock_process_clip_url): - """Test that frame_image_url is handled correctly when called from worker thread""" - # Setup mocks - mock_process_clip_url.return_value = { - "best_frame_info": { - "frame_path": "/path/to/frame.jpg" - } - } - mock_get_image_url.return_value = "http://example.com/images/frame.jpg" - mock_db_client.get_clip_result.return_value = None - mock_db_client.save_clip_result.return_value = True - - # Test direct processing (not from worker) - result = process_clip_request( - clip_url="https://clips.twitch.tv/test", - clip_id="test123", - debug=False, - force=True, - include_image=True, - add_to_queue=False, - from_worker=False +from unittest.mock import MagicMock, patch + +import pytest + +import api_server + + +class _StopLoop(Exception): + """Sentinel used to break process_queue_worker's `while True` after one pass.""" + + +# --------------------------------------------------------------------------- # +# parse_bool_param +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "value, default, expected", + [ + ("1", False, True), + ("TRUE", False, True), + ("yes", False, True), + ("on", False, True), + ("0", True, False), + ("False", True, False), + ("no", True, False), + ("off", True, False), + (None, True, True), + (None, False, False), + ("unknown", False, False), + ("unknown", True, True), + ], +) +def test_parse_bool_param(value, default, expected): + assert api_server.parse_bool_param(value, default) is expected + + +# --------------------------------------------------------------------------- # +# extract_clip_id +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "url, expected", + [ + ("https://clips.twitch.tv/AbcDef123", "AbcDef123"), + ("https://www.twitch.tv/streamer/clip/Funny-Clip-Name", "Funny-Clip-Name"), + ("https://example.com/x?clip=My-Clip-99", "My-Clip-99"), + # pattern 1 stops at the first non-alnum (hyphen not allowed there) + ("https://clips.twitch.tv/Foo-Bar", "Foo"), + # no pattern matches -> last path segment fallback + ("https://example.com/some/path/segment", "segment"), + ], +) +def test_extract_clip_id(url, expected): + assert api_server.extract_clip_id(url) == expected + + +@pytest.mark.parametrize( + "url", + [ + "https://example.com", # bare domain, no path + "https://example.com/", # trailing slash only + "https://clips.twitch.tv/?x=1", # query only, no clip slug + ], +) +def test_extract_clip_id_junk_urls_return_none(url): + # BUG TARGET #4: for URLs with no usable clip slug, extract_clip_id falls + # through to the last path segment and returns "" (empty string) instead of + # None. An empty clip_id then becomes a cache key / queue-dedup key downstream + # and can collide across unrelated requests. Correct behavior: return None. + assert api_server.extract_clip_id(url) is None + + +# --------------------------------------------------------------------------- # +# reset_stuck_processing_requests +# --------------------------------------------------------------------------- # +def test_reset_stuck_counts_both_started_and_null_started(): + db = MagicMock() + cursor = MagicMock() + db._get_connection.return_value.cursor.return_value = cursor + # one row with started_at, two rows with NULL started_at -> total 3 + cursor.fetchall.side_effect = [[("r1",)], [("r2",), ("r3",)]] + with patch.object(api_server, "db_client", db): + total = api_server.reset_stuck_processing_requests(timeout_minutes=2) + assert total == 3 + assert cursor.execute.call_count == 2 + db._get_connection.return_value.commit.assert_called_once() + + +def test_reset_stuck_returns_zero_on_db_error(): + db = MagicMock() + db._get_connection.return_value.cursor.return_value.execute.side_effect = RuntimeError("boom") + with patch.object(api_server, "db_client", db): + assert api_server.reset_stuck_processing_requests() == 0 + + +# --------------------------------------------------------------------------- # +# process_stream_request +# --------------------------------------------------------------------------- # +def test_process_stream_request_direct_returns_result(): + # conftest sets RUN_LOCALLY=true, which forces add_to_queue=False (direct path). + stream_result = {"players": [{"player_name": "x"}]} + with patch.object(api_server, "process_stream_username", return_value=stream_result) as psu: + result = api_server.process_stream_request( + "streamer", num_frames=3, include_image=False, from_worker=True + ) + psu.assert_called_once() + assert result["players"] == [{"player_name": "x"}] + assert "processing_time" in result + + +def test_process_stream_request_direct_handles_no_result(): + with patch.object(api_server, "process_stream_username", return_value=None): + result = api_server.process_stream_request( + "streamer", include_image=False, from_worker=True + ) + assert "error" in result + + +def test_process_stream_request_new_insert_starts_worker(monkeypatch): + # A fresh enqueue (no 'deduplicated' flag) must start the worker and report + # it as newly queued -- NOT as "already in the queue". + monkeypatch.setenv("RUN_LOCALLY", "false") + db = MagicMock() + db.add_to_queue.return_value = ("rid", {"status": "pending", "position": 2, "estimated_wait_seconds": 30}) + with patch.object(api_server, "db_client", db), \ + patch.object(api_server, "start_worker_thread") as start_worker: + result = api_server.process_stream_request("streamer", add_to_queue=True) + assert result["queued"] is True + assert result["request_id"] == "rid" + assert result["position"] == 2 + assert "already in the processing queue" not in result.get("message", "") + start_worker.assert_called_once() + + +def test_process_stream_request_dedup_hit_does_not_start_worker(monkeypatch): + monkeypatch.setenv("RUN_LOCALLY", "false") + db = MagicMock() + db.add_to_queue.return_value = ( + "existing", {"status": "pending", "position": 5, "deduplicated": True}, + ) + with patch.object(api_server, "db_client", db), \ + patch.object(api_server, "start_worker_thread") as start_worker: + result = api_server.process_stream_request("streamer", add_to_queue=True) + assert result["request_id"] == "existing" + assert result["message"] == "This stream is already in the processing queue" + start_worker.assert_not_called() + + +def test_process_clip_request_dedup_hit_reports_already_queued(monkeypatch): + monkeypatch.setenv("RUN_LOCALLY", "false") + db = MagicMock() + db.get_clip_result.return_value = None + db.check_for_match_processing.return_value = {"found": False} + db.add_to_queue.return_value = ( + "existing", {"status": "processing", "position": 1, "deduplicated": True}, + ) + with patch.object(api_server, "db_client", db), \ + patch.object(api_server, "start_worker_thread") as start_worker: + result = api_server.process_clip_request( + clip_url="u", clip_id="abc", add_to_queue=True, ) + assert result["message"] == "This clip is currently being processed" + start_worker.assert_not_called() + + +# --------------------------------------------------------------------------- # +# get_image_url +# --------------------------------------------------------------------------- # +def test_get_image_url_builds_url_from_request_host(tmp_path): + fake_request = MagicMock() + fake_request.host_url = "http://vision.local/" + with patch.object(api_server, "IMAGE_DIR", tmp_path), \ + patch.object(api_server, "request", fake_request), \ + patch("shutil.copy2") as copy2: + image_url, saved = api_server.get_image_url("/frames/best.jpg", "clip42") + copy2.assert_called_once() + assert image_url == "http://vision.local/images/clip42.jpg" + assert saved == image_url + + +def test_get_image_url_returns_none_on_copy_failure(tmp_path): + with patch.object(api_server, "IMAGE_DIR", tmp_path), \ + patch("shutil.copy2", side_effect=OSError("disk full")): + assert api_server.get_image_url("/frames/best.jpg", "clip42") == (None, None) + + +# --------------------------------------------------------------------------- # +# process_queue_worker dispatch +# --------------------------------------------------------------------------- # +def _run_worker_once(db, first_request): + """Drive process_queue_worker through exactly one request then stop.""" + db.get_next_pending_request.side_effect = [first_request, _StopLoop()] + with patch.object(api_server, "db_client", db), \ + patch.object(api_server.time, "sleep"): + # _StopLoop propagates to the worker's broad except and ends the loop. + api_server.process_queue_worker() + + +def test_worker_clip_branch_saves_result_without_frame_image_url(): + db = MagicMock() + clip_request = { + "request_id": "req1", + "request_type": "clip", + "clip_url": "https://clips.twitch.tv/abc", + "clip_id": "abc", + "debug": False, + "force": False, + "include_image": True, + } + worker_result = {"players": [{"player_name": "x"}], "frame_image_url": "http://img/x.jpg"} + + with patch.object(api_server, "process_clip_request", return_value=worker_result) as pcr: + _run_worker_once(db, clip_request) + + # routed to clip processing as a worker call + assert pcr.call_count == 1 + assert pcr.call_args.kwargs["from_worker"] is True + assert pcr.call_args.kwargs["add_to_queue"] is False + + # frame_image_url stripped before persisting + db.save_clip_result.assert_called_once() + saved_result = db.save_clip_result.call_args[0][2] + assert "frame_image_url" not in saved_result - # Verify frame_image_url is included - self.assertIn("frame_image_url", result) - mock_get_image_url.assert_called_once() - - # Reset mocks - mock_get_image_url.reset_mock() - mock_db_client.save_clip_result.reset_mock() - - # Test processing from worker - result = process_clip_request( - clip_url="https://clips.twitch.tv/test", - clip_id="test123", - debug=False, - force=True, - include_image=True, - add_to_queue=False, - from_worker=True + # status transitions: processing -> completed + statuses = [c.args[1] for c in db.update_queue_status.call_args_list] + assert statuses == ["processing", "completed"] + + +def test_worker_stream_branch_does_not_call_save_clip_result(): + db = MagicMock() + stream_request = { + "request_id": "req2", + "request_type": "stream", + "stream_username": "streamer", + "num_frames": 3, + "debug": False, + "include_image": True, + } + worker_result = {"players": [{"player_name": "x"}]} + + with patch.object(api_server, "process_stream_request", return_value=worker_result) as psr: + _run_worker_once(db, stream_request) + + assert psr.call_count == 1 + assert psr.call_args.kwargs["from_worker"] is True + db.save_clip_result.assert_not_called() + statuses = [c.args[1] for c in db.update_queue_status.call_args_list] + assert statuses == ["processing", "completed"] + + +def test_worker_marks_failed_when_no_players_detected(): + db = MagicMock() + clip_request = { + "request_id": "req3", + "request_type": "clip", + "clip_url": "https://clips.twitch.tv/abc", + "clip_id": "abc", + "debug": False, + "force": False, + "include_image": True, + } + with patch.object(api_server, "process_clip_request", return_value={"players": []}): + _run_worker_once(db, clip_request) + + statuses = [c.args[1] for c in db.update_queue_status.call_args_list] + assert statuses == ["processing", "failed"] + db.save_clip_result.assert_not_called() + + +# --------------------------------------------------------------------------- # +# process_clip_request cache matrix (force=False) +# --------------------------------------------------------------------------- # +def test_clip_request_returns_existing_draft_for_match(): + db = MagicMock() + db.get_latest_draft_for_match.return_value = {"is_draft": True, "clip_id": "old"} + with patch.object(api_server, "db_client", db): + result = api_server.process_clip_request( + clip_url="u", clip_id="abc", match_id="m1", only_draft=True, + add_to_queue=False, from_worker=True, ) + assert result["match_id"] == "m1" + db.get_clip_result.assert_not_called() + - # Verify frame_image_url is NOT included when called from worker thread - self.assertNotIn("frame_image_url", result) - mock_get_image_url.assert_not_called() - - # Verify frame_image_url is removed before saving to database - mock_db_client.save_clip_result.assert_called_once() - saved_result = mock_db_client.save_clip_result.call_args[0][2] - self.assertNotIn("frame_image_url", saved_result) - - @patch("api_server.db_client") - def test_get_cached_result_from_worker(self, mock_db_client): - """Test that frame_image_url is not added to cached results when called from worker thread""" - # Setup mock - mock_db_client.get_clip_result.return_value = { - "best_frame_path": "/path/to/frame.jpg" - } - - # When from_worker is False, we should try to add frame_image_url - with patch("api_server.Path.exists", return_value=True), \ - patch("api_server.get_image_url", return_value="http://example.com/images/frame.jpg") as mock_get_image_url: - - # Test retrieving cached result (not from worker) - result = process_clip_request( - clip_url="https://clips.twitch.tv/test", - clip_id="test123", - debug=False, - force=False, - include_image=True, - add_to_queue=False, - from_worker=False - ) - - # Verify we attempted to add frame_image_url - self.assertIn("frame_image_url", result) - mock_get_image_url.assert_called_once() - - # Reset mocks - mock_get_image_url.reset_mock() - - # Test retrieving cached result (from worker) - result = process_clip_request( - clip_url="https://clips.twitch.tv/test", - clip_id="test123", - debug=False, - force=False, - include_image=True, - add_to_queue=False, - from_worker=True - ) - - # Verify we did NOT attempt to add frame_image_url - self.assertNotIn("frame_image_url", result) - mock_get_image_url.assert_not_called() - - @patch("api_server.db_client") - @patch("api_server.process_clip_request") - def test_process_queue_worker_removes_frame_image_url(self, mock_process_clip_request, mock_db_client): - """Test that process_queue_worker handles frame_image_url correctly""" - # Setup mocks - mock_db_client.is_queue_processing.side_effect = [False, True] # Run only once - mock_db_client.get_next_pending_request.return_value = { - "request_id": "req123", - "request_type": "clip", - "clip_url": "https://clips.twitch.tv/test", - "clip_id": "test123", - "debug": False, - "force": False, - "include_image": True - } - - # Mock the result from process_clip_request including a frame_image_url - mock_process_clip_request.return_value = { - "best_frame_info": {"frame_path": "/path/to/frame.jpg"}, - "frame_image_url": "http://example.com/images/frame.jpg" - } - - # Run the worker - with patch("api_server.time.sleep"): # Skip sleep - process_queue_worker() - - # Verify process_clip_request was called with from_worker=True - mock_process_clip_request.assert_called_once_with( - ANY, ANY, ANY, ANY, ANY, add_to_queue=False, from_worker=True +def test_clip_request_reuses_completed_match_result(): + db = MagicMock() + db.check_for_match_processing.return_value = {"found": True, "status": "completed", "clip_id": "old"} + db.get_clip_result.return_value = {"players": [{"player_name": "x"}]} + with patch.object(api_server, "db_client", db): + result = api_server.process_clip_request( + clip_url="u", clip_id="abc", match_id="m1", only_draft=False, + add_to_queue=False, from_worker=True, ) + assert result["match_id"] == "m1" + assert result["clip_id"] == "old" - # Verify frame_image_url is removed before saving to database - mock_db_client.save_clip_result.assert_called_once() - # Check if first argument of the third call has frame_image_url removed - # (db_client.update_queue_status will be called first, then save_clip_result) - saved_args = mock_db_client.save_clip_result.call_args[0] +def test_clip_request_returns_cached_draft_when_only_draft(): + db = MagicMock() + db.get_clip_result.return_value = {"is_draft": True, "players": [], "saved_image_path": None} + with patch.object(api_server, "db_client", db): + result = api_server.process_clip_request( + clip_url="u", clip_id="abc", only_draft=True, + add_to_queue=False, from_worker=True, + ) + assert result["is_draft"] is True - # The result should be the third argument (index 2) - saved_result = saved_args[2] if len(saved_args) > 2 else {} - # Verify frame_image_url was removed - self.assertNotIn("frame_image_url", saved_result) +def test_clip_request_returns_filtered_cache_for_non_draft(): + db = MagicMock() + db.get_clip_result.return_value = { + "is_draft": False, + "players": [{"player_name": "x"}], + "heroes": [{"name": "h"}], + "saved_image_path": "/img/x.jpg", + "extra": "should-be-dropped", + } + with patch.object(api_server, "db_client", db): + result = api_server.process_clip_request( + clip_url="u", clip_id="abc", only_draft=False, + add_to_queue=False, from_worker=True, + ) + assert set(result.keys()) == {"saved_image_path", "players", "heroes"} + assert "extra" not in result -if __name__ == "__main__": - unittest.main() +def test_clip_request_force_skips_cache_and_processes(): + db = MagicMock() + with patch.object(api_server, "db_client", db), \ + patch.object(api_server, "process_clip_url", return_value={"players": [{"player_name": "x"}]}) as pcu: + result = api_server.process_clip_request( + clip_url="u", clip_id="abc", force=True, + add_to_queue=False, from_worker=True, include_image=False, + ) + db.get_clip_result.assert_not_called() + pcu.assert_called_once() + assert result["players"] == [{"player_name": "x"}] diff --git a/packages/clip-processor-py/tests/test_api_server_http.py b/packages/clip-processor-py/tests/test_api_server_http.py new file mode 100644 index 000000000..5da6f4e07 --- /dev/null +++ b/packages/clip-processor-py/tests/test_api_server_http.py @@ -0,0 +1,128 @@ +"""HTTP-layer tests for api_server routes via Flask's test client. + +These exercise the previously-untested web surface: the image-serving security +checks, the API-key decorator, and the health/metrics/queue-status endpoints. +The `@app.before_request` hook would otherwise run the heavy `initialize_app` +(network + template load), so the fixture marks the app pre-initialized. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +import api_server + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setattr(api_server, "app_initialized", True) + api_server.app.config.update(TESTING=True) + return api_server.app.test_client() + + +# --------------------------------------------------------------------------- # +# /health (no auth) +# --------------------------------------------------------------------------- # +def test_health_ok_without_auth(client): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.get_json()["status"] == "ok" + + +# --------------------------------------------------------------------------- # +# require_api_key +# --------------------------------------------------------------------------- # +def test_metrics_requires_key_when_not_local(client, monkeypatch): + monkeypatch.setenv("RUN_LOCALLY", "false") + monkeypatch.setattr(api_server, "API_KEY", "secret") + resp = client.get("/metrics") + assert resp.status_code == 401 + + +def test_metrics_accepts_valid_key(client, monkeypatch): + monkeypatch.setenv("RUN_LOCALLY", "false") + monkeypatch.setattr(api_server, "API_KEY", "secret") + with patch.object(api_server, "psutil") as ps: + proc = ps.Process.return_value + proc.cpu_percent.return_value = 1.0 + proc.memory_info.return_value.rss = 1024 * 1024 * 50 + proc.num_threads.return_value = 7 + resp = client.get("/metrics", headers={"X-API-Key": "secret"}) + assert resp.status_code == 200 + body = resp.get_json() + assert body["num_threads"] == 7 + assert body["memory_mb"] == 50.0 + + +def test_local_mode_bypasses_auth(client, monkeypatch): + # conftest sets RUN_LOCALLY=true; metrics should serve with no key. + with patch.object(api_server, "psutil") as ps: + proc = ps.Process.return_value + proc.cpu_percent.return_value = 0.0 + proc.memory_info.return_value.rss = 0 + proc.num_threads.return_value = 1 + resp = client.get("/metrics") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- # +# /queue/status/ +# --------------------------------------------------------------------------- # +def test_queue_status_not_found(client): + db = MagicMock() + db.get_queue_status.return_value = None + with patch.object(api_server, "db_client", db): + resp = client.get("/queue/status/req-x") + assert resp.status_code == 404 + + +def test_queue_status_pending(client): + db = MagicMock() + db.get_queue_status.return_value = {"status": "pending", "position": 3, "clip_id": "c1"} + with patch.object(api_server, "db_client", db): + resp = client.get("/queue/status/req-1") + assert resp.status_code == 200 + body = resp.get_json() + assert body["status"] == "pending" + assert body["position"] == 3 + + +def test_queue_status_completed_attaches_clip_result(client): + db = MagicMock() + db.get_queue_status.return_value = { + "status": "completed", "request_type": "clip", "result_id": "c1", + } + db.get_clip_result.return_value = {"players": [{"player_name": "x"}]} + with patch.object(api_server, "db_client", db): + resp = client.get("/queue/status/req-2") + assert resp.status_code == 200 + assert resp.get_json()["result"]["players"] == [{"player_name": "x"}] + + +# --------------------------------------------------------------------------- # +# /images/ security +# --------------------------------------------------------------------------- # +def test_serve_image_rejects_dotdot(client): + resp = client.get("/images/foo..jpg") + assert resp.status_code == 400 + + +def test_serve_image_rejects_bad_extension(client): + resp = client.get("/images/evil.exe") + assert resp.status_code == 400 + + +def test_serve_image_404_for_missing_file(client, tmp_path): + with patch.object(api_server, "IMAGE_DIR", tmp_path): + resp = client.get("/images/missing.jpg") + assert resp.status_code == 404 + + +def test_serve_image_serves_existing_file_with_security_headers(client, tmp_path): + img = tmp_path / "good.jpg" + img.write_bytes(b"\xff\xd8\xff\xe0jpegbytes") + with patch.object(api_server, "IMAGE_DIR", tmp_path): + resp = client.get("/images/good.jpg") + assert resp.status_code == 200 + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert "no-store" in resp.headers["Cache-Control"] diff --git a/packages/clip-processor-py/tests/test_clip_utils.py b/packages/clip-processor-py/tests/test_clip_utils.py index e3d514e0a..360548cff 100644 --- a/packages/clip-processor-py/tests/test_clip_utils.py +++ b/packages/clip-processor-py/tests/test_clip_utils.py @@ -1,21 +1,14 @@ -#!/usr/bin/env python3 -"""Tests for clip_utils download-URL resolution (quality fallback on CDN 404).""" +"""Tests for clip_utils.get_clip_details: CDN quality fallback + retry/backoff. + +Ported from the old unittest module and extended with retry characterization so +the retry loop's behavior is pinned before it gets extracted into a shared helper. +""" import json -import sys -import unittest -from pathlib import Path from unittest.mock import MagicMock, patch from urllib.parse import quote -# Add the src directory to the Python path -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -# Stub heavy/optional module-level imports so the test runs fully offline without -# requiring opencv/numpy to be installed. clip_utils only uses these for the -# frame-extraction paths, not for get_clip_details. -for _mod in ("cv2", "numpy", "bs4", "tqdm"): - sys.modules.setdefault(_mod, MagicMock()) +import pytest import clip_utils @@ -57,87 +50,190 @@ def probe(status_code): return resp -class ClipUtilsResolveTests(unittest.TestCase): - def test_falls_back_to_720_when_1080_is_404(self): - qualities = [ - {"quality": "1080", "sourceURL": URL_1080}, - {"quality": "720", "sourceURL": URL_720}, - ] - token = make_token(URL_720) - - with patch("clip_utils.requests") as mock_requests: - mock_requests.RequestException = Exception - mock_requests.post.return_value = gql_response(qualities, token) - # 1080 probe -> 404, 720 probe -> 206 - mock_requests.get.side_effect = [probe(404), probe(206)] - - details = clip_utils.get_clip_details( - f"https://clips.twitch.tv/{CLIP_SLUG}" - ) - - self.assertEqual(details["selected_quality"], "720") - self.assertTrue(details["download_url"].startswith(URL_720)) - self.assertIn(f"sig={token['signature']}", details["download_url"]) - self.assertIn(quote(token["value"]), details["download_url"]) - - def test_uses_1080_when_available(self): - qualities = [ - {"quality": "1080", "sourceURL": URL_1080}, - {"quality": "720", "sourceURL": URL_720}, - ] - token = make_token(URL_720) - - with patch("clip_utils.requests") as mock_requests: - mock_requests.RequestException = Exception - mock_requests.post.return_value = gql_response(qualities, token) - mock_requests.get.side_effect = [probe(206)] - - details = clip_utils.get_clip_details( - f"https://clips.twitch.tv/{CLIP_SLUG}" - ) - - self.assertEqual(details["selected_quality"], "1080") - self.assertTrue(details["download_url"].startswith(URL_1080)) - - def test_clip_uri_fallback_when_all_qualities_404(self): - # clip_uri points to a rendition not present in videoQualities. - qualities = [{"quality": "1080", "sourceURL": URL_1080}] - token = make_token(URL_720) - - with patch("clip_utils.requests") as mock_requests: - mock_requests.RequestException = Exception - mock_requests.post.return_value = gql_response(qualities, token) - # 1080 -> 404, then clip_uri (720) -> 206 - mock_requests.get.side_effect = [probe(404), probe(206)] - - details = clip_utils.get_clip_details( - f"https://clips.twitch.tv/{CLIP_SLUG}" +# --------------------------------------------------------------------------- # +# CDN quality fallback +# --------------------------------------------------------------------------- # +def test_falls_back_to_720_when_1080_is_404(): + qualities = [ + {"quality": "1080", "sourceURL": URL_1080}, + {"quality": "720", "sourceURL": URL_720}, + ] + token = make_token(URL_720) + with patch("clip_utils.requests") as mock_requests: + mock_requests.RequestException = Exception + mock_requests.post.return_value = gql_response(qualities, token) + mock_requests.get.side_effect = [probe(404), probe(206)] + details = clip_utils.get_clip_details(f"https://clips.twitch.tv/{CLIP_SLUG}") + assert details["selected_quality"] == "720" + assert details["download_url"].startswith(URL_720) + assert f"sig={token['signature']}" in details["download_url"] + assert quote(token["value"]) in details["download_url"] + + +def test_uses_1080_when_available(): + qualities = [ + {"quality": "1080", "sourceURL": URL_1080}, + {"quality": "720", "sourceURL": URL_720}, + ] + token = make_token(URL_720) + with patch("clip_utils.requests") as mock_requests: + mock_requests.RequestException = Exception + mock_requests.post.return_value = gql_response(qualities, token) + mock_requests.get.side_effect = [probe(206)] + details = clip_utils.get_clip_details(f"https://clips.twitch.tv/{CLIP_SLUG}") + assert details["selected_quality"] == "1080" + assert details["download_url"].startswith(URL_1080) + + +def test_clip_uri_fallback_when_all_qualities_404(): + qualities = [{"quality": "1080", "sourceURL": URL_1080}] + token = make_token(URL_720) + with patch("clip_utils.requests") as mock_requests: + mock_requests.RequestException = Exception + mock_requests.post.return_value = gql_response(qualities, token) + mock_requests.get.side_effect = [probe(404), probe(206)] + details = clip_utils.get_clip_details(f"https://clips.twitch.tv/{CLIP_SLUG}") + assert details["selected_quality"] == "720" + assert details["download_url"].startswith(URL_720) + + +def test_returns_highest_when_every_candidate_404(): + qualities = [ + {"quality": "1080", "sourceURL": URL_1080}, + {"quality": "720", "sourceURL": URL_720}, + ] + token = make_token(URL_720) + with patch("clip_utils.requests") as mock_requests: + mock_requests.RequestException = Exception + mock_requests.post.return_value = gql_response(qualities, token) + mock_requests.get.side_effect = [probe(404), probe(404)] + details = clip_utils.get_clip_details(f"https://clips.twitch.tv/{CLIP_SLUG}") + assert details["selected_quality"] == "1080" + assert details["download_url"].startswith(URL_1080) + + +# --------------------------------------------------------------------------- # +# Retry / backoff +# --------------------------------------------------------------------------- # +def test_retries_then_raises_after_max_attempts(): + with patch("clip_utils.requests") as mock_requests, \ + patch("clip_utils.time.sleep") as sleep: + mock_requests.RequestException = Exception + mock_requests.post.side_effect = RuntimeError("network down") + with pytest.raises(RuntimeError, match="network down"): + clip_utils.get_clip_details( + f"https://clips.twitch.tv/{CLIP_SLUG}", max_retries=3, retry_delay=2 ) - - self.assertEqual(details["selected_quality"], "720") - self.assertTrue(details["download_url"].startswith(URL_720)) - - def test_returns_highest_when_every_candidate_404(self): - qualities = [ - {"quality": "1080", "sourceURL": URL_1080}, - {"quality": "720", "sourceURL": URL_720}, - ] - token = make_token(URL_720) - - with patch("clip_utils.requests") as mock_requests: - mock_requests.RequestException = Exception - mock_requests.post.return_value = gql_response(qualities, token) - # both quality probes 404; clip_uri (== 720 url) is deduped, so 2 probes - mock_requests.get.side_effect = [probe(404), probe(404)] - - details = clip_utils.get_clip_details( - f"https://clips.twitch.tv/{CLIP_SLUG}" - ) - - # Last resort: highest advertised quality - self.assertEqual(details["selected_quality"], "1080") - self.assertTrue(details["download_url"].startswith(URL_1080)) - - -if __name__ == "__main__": - unittest.main() + # 3 attempts, sleeping between (not after the last) with 1.5x backoff + assert mock_requests.post.call_count == 3 + assert [c.args[0] for c in sleep.call_args_list] == [2, 3.0] + + +def test_succeeds_on_second_attempt(): + qualities = [{"quality": "1080", "sourceURL": URL_1080}] + token = make_token(URL_1080) + with patch("clip_utils.requests") as mock_requests, \ + patch("clip_utils.time.sleep"): + mock_requests.RequestException = Exception + mock_requests.post.side_effect = [RuntimeError("blip"), gql_response(qualities, token)] + mock_requests.get.side_effect = [probe(206)] + details = clip_utils.get_clip_details( + f"https://clips.twitch.tv/{CLIP_SLUG}", max_retries=3 + ) + assert details["selected_quality"] == "1080" + assert mock_requests.post.call_count == 2 + + +# --------------------------------------------------------------------------- # +# _build_download_url +# --------------------------------------------------------------------------- # +def test_build_download_url_encodes_token_and_signature(): + token = make_token(URL_1080) + url = clip_utils._build_download_url(URL_1080, token) + assert url.startswith(URL_1080 + "?token=") + assert quote(token["value"]) in url + assert f"sig={token['signature']}" in url + + +# --------------------------------------------------------------------------- # +# _resolve_available_download_url +# --------------------------------------------------------------------------- # +def test_resolve_skips_404_and_returns_first_206(): + qualities = [ + {"quality": "1080", "sourceURL": URL_1080}, + {"quality": "720", "sourceURL": URL_720}, + ] + token = make_token(URL_720) + with patch("clip_utils.requests") as mock_requests: + mock_requests.RequestException = Exception + mock_requests.get.side_effect = [probe(404), probe(206)] + url, quality = clip_utils._resolve_available_download_url(qualities, token) + assert quality == "720" + assert url.startswith(URL_720) + + +def test_resolve_falls_back_to_clip_uri_when_advertised_404(): + # Only 1080 advertised (and 404); token's clip_uri points at a real 720 file. + qualities = [{"quality": "1080", "sourceURL": URL_1080}] + token = make_token(URL_720) + with patch("clip_utils.requests") as mock_requests: + mock_requests.RequestException = Exception + mock_requests.get.side_effect = [probe(404), probe(206)] + url, quality = clip_utils._resolve_available_download_url(qualities, token) + assert url.startswith(URL_720) + assert quality == "720" # parsed from /720/index.mp4 + + +def test_resolve_returns_highest_when_every_candidate_404(): + qualities = [ + {"quality": "1080", "sourceURL": URL_1080}, + {"quality": "720", "sourceURL": URL_720}, + ] + token = make_token(URL_720) + with patch("clip_utils.requests") as mock_requests: + mock_requests.RequestException = Exception + mock_requests.get.side_effect = [probe(404), probe(404)] + url, quality = clip_utils._resolve_available_download_url(qualities, token) + assert quality == "1080" + + +def test_resolve_raises_when_no_candidates(): + token = {"value": json.dumps({}), "signature": "s"} # no clip_uri + with patch("clip_utils.requests") as mock_requests: + mock_requests.RequestException = Exception + with pytest.raises(ValueError, match="No valid sourceURL"): + clip_utils._resolve_available_download_url([], token) + + +# --------------------------------------------------------------------------- # +# get_clip_details — malformed GQL +# --------------------------------------------------------------------------- # +def test_get_clip_details_raises_when_clip_missing(): + resp = MagicMock() + resp.raise_for_status.return_value = None + resp.json.return_value = {"data": {"clip": None}} + with patch("clip_utils.requests") as mock_requests, \ + patch("clip_utils.time.sleep"): + mock_requests.RequestException = Exception + mock_requests.post.return_value = resp + with pytest.raises(ValueError, match="Clip not found"): + clip_utils.get_clip_details(f"https://clips.twitch.tv/{CLIP_SLUG}", max_retries=1) + + +# --------------------------------------------------------------------------- # +# download_single_frame — early-return seams (no network/cv2) +# --------------------------------------------------------------------------- # +def test_download_single_frame_raises_without_url(): + with pytest.raises(ValueError, match="No download URL"): + clip_utils.download_single_frame({"id": "c1"}) + + +def test_download_single_frame_reuses_existing_frame(tmp_path): + frames_dir = tmp_path / "frames" + frames_dir.mkdir() + (frames_dir / "c1.jpg").write_bytes(b"cached") + with patch.object(clip_utils, "TEMP_DIR", tmp_path), \ + patch("clip_utils.requests") as mock_requests: + out = clip_utils.download_single_frame({"id": "c1", "download_url": "http://x"}) + assert out.endswith("c1.jpg") + mock_requests.get.assert_not_called() # reused, never hit the network diff --git a/packages/clip-processor-py/tests/test_dota_heroes.py b/packages/clip-processor-py/tests/test_dota_heroes.py index 3a282ca3b..fb7853aca 100644 --- a/packages/clip-processor-py/tests/test_dota_heroes.py +++ b/packages/clip-processor-py/tests/test_dota_heroes.py @@ -1,21 +1,14 @@ -#!/usr/bin/env python3 -"""Tests for Dota hero roster refresh behavior.""" +"""Tests for Dota hero roster refresh behavior (ported to pytest).""" import json -import sys import tempfile -import unittest from pathlib import Path from unittest.mock import patch -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - import dota_heroes class FakeImageResponse: - """Small requests.Response stand in for image downloads.""" - def raise_for_status(self): return None @@ -24,8 +17,6 @@ def iter_content(self, chunk_size=8192): class FakeJsonResponse: - """Small requests.Response stand in for JSON metadata.""" - def __init__(self, data): self.data = data @@ -36,9 +27,9 @@ def json(self): return self.data -class DotaHeroesTests(unittest.TestCase): - def test_parse_valve_hero_list_normalizes_new_heroes(self): - heroes = dota_heroes.parse_valve_hero_list({ +def test_parse_valve_hero_list_normalizes_new_heroes(): + heroes = dota_heroes.parse_valve_hero_list( + { "result": { "data": { "heroes": [ @@ -51,131 +42,215 @@ def test_parse_valve_hero_list_normalizes_new_heroes(self): ] } } - }) + } + ) + assert heroes[0]["id"] == 155 + assert heroes[0]["tag"] == "largo" + assert heroes[0]["localized_name"] == "Largo" - self.assertEqual(heroes[0]["id"], 155) - self.assertEqual(heroes[0]["tag"], "largo") - self.assertEqual(heroes[0]["localized_name"], "Largo") - def test_get_hero_list_uses_valve_roster_and_spectral_alticons(self): - valve_response = { - "result": { - "data": { - "heroes": [ - { - "id": 5, - "name": "npc_dota_hero_crystal_maiden", - "name_english_loc": "Crystal Maiden", - }, - { - "id": 155, - "name": "npc_dota_hero_largo", - "name_english_loc": "Largo", - }, - ] - } - } - } - odota_response = {} - spectral_response = { - "result": { +def test_get_hero_list_uses_valve_roster_and_spectral_alticons(): + valve_response = { + "result": { + "data": { "heroes": [ - { - "id": 5, - "name": "npc_dota_hero_crystal_maiden", - "tag": "crystal_maiden", - "localized_name": "Crystal Maiden", - "aliases": "cm", - "alticons": ["arcana"], - } + {"id": 5, "name": "npc_dota_hero_crystal_maiden", "name_english_loc": "Crystal Maiden"}, + {"id": 155, "name": "npc_dota_hero_largo", "name_english_loc": "Largo"}, ] } } - - with patch.object( - dota_heroes.requests, - "get", - side_effect=[ - FakeJsonResponse(valve_response), - FakeJsonResponse(odota_response), - FakeJsonResponse(spectral_response), - ], - ): - heroes = dota_heroes.get_hero_list() - - crystal_maiden = next(hero for hero in heroes if hero["id"] == 5) - largo = next(hero for hero in heroes if hero["id"] == 155) - - self.assertEqual(crystal_maiden["alticons"], ["arcana"]) - self.assertEqual(crystal_maiden["aliases"], "cm") - self.assertEqual(largo["localized_name"], "Largo") - - def test_get_hero_data_refreshes_missing_remote_hero_and_missing_variants(self): - with tempfile.TemporaryDirectory() as temp_dir: - assets_dir = Path(temp_dir) / "dota_heroes" - assets_dir.mkdir(parents=True) - - (assets_dir / "5_base.png").write_bytes(b"existing-base") - (assets_dir / "5_persona1.png").write_bytes(b"existing-persona") - (assets_dir / "templates_cache.npz").write_bytes(b"stale-cache") - - cached_hero_data = [ + } + odota_response = {} + spectral_response = { + "result": { + "heroes": [ { "id": 5, "name": "npc_dota_hero_crystal_maiden", "tag": "crystal_maiden", "localized_name": "Crystal Maiden", "aliases": "cm", - "alt_name": "Rylai", - "variants": [ - { - "variant": "base", - "image_path": str(assets_dir / "5_base.png"), - "image_url": "https://old.example/crystal_maiden.png", - }, - { - "variant": "persona1", - "image_path": str(assets_dir / "5_persona1.png"), - "image_url": "https://old.example/crystal_maiden_persona1.png", - }, - ], + "alticons": ["arcana"], } ] - (assets_dir / "hero_data.json").write_text(json.dumps(cached_hero_data)) + } + } + with patch.object( + dota_heroes.requests, + "get", + side_effect=[ + FakeJsonResponse(valve_response), + FakeJsonResponse(odota_response), + FakeJsonResponse(spectral_response), + ], + ): + heroes = dota_heroes.get_hero_list() - current_heroes = [ - dota_heroes.normalize_hero({ - "id": 5, - "name": "npc_dota_hero_crystal_maiden", - "name_english_loc": "Crystal Maiden", - "alticons": ["arcana"], - }), - dota_heroes.normalize_hero({ - "id": 155, - "name": "npc_dota_hero_largo", - "name_english_loc": "Largo", - }), - ] + crystal_maiden = next(h for h in heroes if h["id"] == 5) + largo = next(h for h in heroes if h["id"] == 155) + assert crystal_maiden["alticons"] == ["arcana"] + assert crystal_maiden["aliases"] == "cm" + assert largo["localized_name"] == "Largo" + + +def test_get_hero_data_refreshes_missing_remote_hero_and_missing_variants(): + with tempfile.TemporaryDirectory() as temp_dir: + assets_dir = Path(temp_dir) / "dota_heroes" + assets_dir.mkdir(parents=True) + + (assets_dir / "5_base.png").write_bytes(b"existing-base") + (assets_dir / "5_persona1.png").write_bytes(b"existing-persona") + (assets_dir / "templates_cache.npz").write_bytes(b"stale-cache") + + cached_hero_data = [ + { + "id": 5, + "name": "npc_dota_hero_crystal_maiden", + "tag": "crystal_maiden", + "localized_name": "Crystal Maiden", + "aliases": "cm", + "alt_name": "Rylai", + "variants": [ + { + "variant": "base", + "image_path": str(assets_dir / "5_base.png"), + "image_url": "https://old.example/crystal_maiden.png", + }, + { + "variant": "persona1", + "image_path": str(assets_dir / "5_persona1.png"), + "image_url": "https://old.example/crystal_maiden_persona1.png", + }, + ], + } + ] + (assets_dir / "hero_data.json").write_text(json.dumps(cached_hero_data)) + + current_heroes = [ + dota_heroes.normalize_hero( + {"id": 5, "name": "npc_dota_hero_crystal_maiden", "name_english_loc": "Crystal Maiden", "alticons": ["arcana"]} + ), + dota_heroes.normalize_hero( + {"id": 155, "name": "npc_dota_hero_largo", "name_english_loc": "Largo"} + ), + ] + + with patch.object(dota_heroes, "ASSETS_DIR", assets_dir), \ + patch.object(dota_heroes, "download_hero_abilities", return_value={}), \ + patch.object(dota_heroes, "get_hero_list", return_value=current_heroes), \ + patch.object(dota_heroes.requests, "get", return_value=FakeImageResponse()): + refreshed = dota_heroes.get_hero_data(refresh=True) + + hero_ids = {h["id"] for h in refreshed} + assert hero_ids == {5, 155} + assert (assets_dir / "5_arcana.png").exists() + assert (assets_dir / "155_base.png").exists() + assert not (assets_dir / "templates_cache.npz").exists() + + crystal_maiden = next(h for h in refreshed if h["id"] == 5) + crystal_variants = {v["variant"] for v in crystal_maiden["variants"]} + assert crystal_variants == {"base", "persona1", "arcana"} + + largo = next(h for h in refreshed if h["id"] == 155) + assert largo["variants"][0]["image_url"] == dota_heroes.steam_hero_image_url("largo") + + +# --------------------------------------------------------------------------- # +# parse_odota_hero_list / parse_legacy_hero_list +# --------------------------------------------------------------------------- # +def test_parse_odota_hero_list_handles_dict_keyed_by_id(): + data = { + "1": {"id": 1, "name": "npc_dota_hero_antimage", "localized_name": "Anti-Mage"}, + "2": {"id": 2, "name": "npc_dota_hero_axe", "localized_name": "Axe"}, + } + heroes = dota_heroes.parse_odota_hero_list(data) + by_id = {h["id"]: h for h in heroes} + assert by_id[1]["tag"] == "antimage" + assert by_id[1]["localized_name"] == "Anti-Mage" + assert by_id[2]["tag"] == "axe" + + +def test_parse_legacy_hero_list_reads_result_heroes(): + data = {"result": {"heroes": [ + {"id": 5, "name": "npc_dota_hero_crystal_maiden", "tag": "crystal_maiden", + "localized_name": "Crystal Maiden", "alticons": ["arcana"]}, + ]}} + heroes = dota_heroes.parse_legacy_hero_list(data) + assert heroes[0]["alticons"] == ["arcana"] + assert heroes[0]["tag"] == "crystal_maiden" + + +# --------------------------------------------------------------------------- # +# merge_hero_metadata +# --------------------------------------------------------------------------- # +def test_merge_hero_metadata_merges_alticons_and_aliases(): + base = [dota_heroes.normalize_hero( + {"id": 5, "name": "npc_dota_hero_crystal_maiden", "name_english_loc": "Crystal Maiden"} + )] + augment = [{"id": 5, "aliases": "cm", "alt_name": "Rylai", "alticons": ["arcana", "winter"]}] + merged = dota_heroes.merge_hero_metadata(base, augment) + assert merged[0]["aliases"] == "cm" + assert merged[0]["alt_name"] == "Rylai" + assert merged[0]["alticons"] == ["arcana", "winter"] # sorted union + + +def test_merge_hero_metadata_keeps_base_when_no_augment_match(): + base = [dota_heroes.normalize_hero( + {"id": 7, "name": "npc_dota_hero_earthshaker", "name_english_loc": "Earthshaker"} + )] + merged = dota_heroes.merge_hero_metadata(base, []) + assert merged[0]["id"] == 7 + assert merged[0]["alticons"] == [] + + +# --------------------------------------------------------------------------- # +# hero_roster_signature +# --------------------------------------------------------------------------- # +def test_roster_signature_changes_when_variant_path_changes(): + hero = {"id": 5, "name": "npc_dota_hero_crystal_maiden", "tag": "crystal_maiden", + "localized_name": "Crystal Maiden", + "variants": [{"variant": "base", "image_path": "/a/5_base.png"}]} + sig_a = dota_heroes.hero_roster_signature([hero]) + hero2 = dict(hero) + hero2["variants"] = [{"variant": "base", "image_path": "/b/5_base.png"}] + sig_b = dota_heroes.hero_roster_signature([hero2]) + assert sig_a == dota_heroes.hero_roster_signature([hero]) # deterministic + assert sig_a != sig_b + + +# --------------------------------------------------------------------------- # +# all_variant_images_exist / get_missing_expected_variants +# --------------------------------------------------------------------------- # +def test_all_variant_images_exist_true_then_false(tmp_path): + img = tmp_path / "5_base.png" + img.write_bytes(b"x") + hero = {"id": 5, "localized_name": "Crystal Maiden", + "variants": [{"variant": "base", "image_path": str(img)}]} + assert dota_heroes.all_variant_images_exist([hero]) is True + + hero_missing = {"id": 6, "localized_name": "Axe", + "variants": [{"variant": "base", "image_path": str(tmp_path / "6_base.png")}]} + assert dota_heroes.all_variant_images_exist([hero_missing]) is False - with patch.object(dota_heroes, "ASSETS_DIR", assets_dir), \ - patch.object(dota_heroes, "download_hero_abilities", return_value={}), \ - patch.object(dota_heroes, "get_hero_list", return_value=current_heroes), \ - patch.object(dota_heroes.requests, "get", return_value=FakeImageResponse()): - refreshed = dota_heroes.get_hero_data(refresh=True) - hero_ids = {hero["id"] for hero in refreshed} - self.assertEqual(hero_ids, {5, 155}) - self.assertTrue((assets_dir / "5_arcana.png").exists()) - self.assertTrue((assets_dir / "155_base.png").exists()) - self.assertFalse((assets_dir / "templates_cache.npz").exists()) +def test_get_missing_expected_variants_detects_new_alticon(): + cached = [{"id": 5, "variants": [{"variant": "base"}]}] + heroes = [{"id": 5, "localized_name": "Crystal Maiden", "alticons": ["arcana"]}] + missing = dota_heroes.get_missing_expected_variants(cached, heroes) + assert (5, "Crystal Maiden", "arcana") in missing + assert all(m[2] != "base" for m in missing) # base already cached - crystal_maiden = next(hero for hero in refreshed if hero["id"] == 5) - crystal_variants = {variant["variant"] for variant in crystal_maiden["variants"]} - self.assertEqual(crystal_variants, {"base", "persona1", "arcana"}) - largo = next(hero for hero in refreshed if hero["id"] == 155) - self.assertEqual(largo["variants"][0]["image_url"], dota_heroes.steam_hero_image_url("largo")) +def test_get_missing_expected_variants_skips_unknown_hero(): + cached = [] # nothing cached + heroes = [{"id": 99, "localized_name": "New", "alticons": ["x"]}] + # hero not in cache -> skipped (handled by full download path, not "missing") + assert dota_heroes.get_missing_expected_variants(cached, heroes) == [] -if __name__ == "__main__": - unittest.main() +# --------------------------------------------------------------------------- # +# get_hero_list fallback when every remote source fails +# --------------------------------------------------------------------------- # +def test_get_hero_list_returns_empty_when_all_sources_raise(): + with patch.object(dota_heroes.requests, "get", side_effect=RuntimeError("network down")): + assert dota_heroes.get_hero_list() == [] diff --git a/packages/clip-processor-py/tests/test_facet_detection.py b/packages/clip-processor-py/tests/test_facet_detection.py new file mode 100644 index 000000000..da4c60216 --- /dev/null +++ b/packages/clip-processor-py/tests/test_facet_detection.py @@ -0,0 +1,87 @@ +"""Pure-logic seam tests for facet_detection. + +cv2/numpy are MagicMock stubs (see conftest), so the pixel-matching core is not +exercised here. We cover the file-backed name-lookup logic of get_hero_abilities +and the team-based corner branch of extract_facet_region, which are plain Python. +""" + +from unittest.mock import MagicMock, mock_open, patch + +import facet_detection + + +# --------------------------------------------------------------------------- # +# get_hero_abilities — guards +# --------------------------------------------------------------------------- # +def test_get_hero_abilities_returns_none_for_empty_name(): + assert facet_detection.get_hero_abilities("") is None + assert facet_detection.get_hero_abilities(None) is None + + +def test_get_hero_abilities_returns_none_when_files_missing(): + with patch("facet_detection.os.path.exists", return_value=False): + assert facet_detection.get_hero_abilities("Crystal Maiden") is None + + +# --------------------------------------------------------------------------- # +# get_hero_abilities — localized -> internal name resolution +# --------------------------------------------------------------------------- # +def test_get_hero_abilities_resolves_via_hero_data_localized_name(): + abilities = {"npc_dota_hero_crystal_maiden": {"abilities": ["frostbite"]}} + hero_data = [{"localized_name": "Crystal Maiden", "name": "npc_dota_hero_crystal_maiden"}] + with patch("facet_detection.os.path.exists", return_value=True), \ + patch("builtins.open", mock_open(read_data="{}")), \ + patch("facet_detection.json.load", side_effect=[abilities, hero_data]): + result = facet_detection.get_hero_abilities("Crystal Maiden") + assert result is not None + assert result["hero_internal_name"] == "npc_dota_hero_crystal_maiden" + assert result["hero_localized_name"] == "Crystal Maiden" + + +def test_get_hero_abilities_returns_none_for_unknown_hero(): + abilities = {"npc_dota_hero_axe": {}} + hero_data = [{"localized_name": "Axe", "name": "npc_dota_hero_axe"}] + with patch("facet_detection.os.path.exists", return_value=True), \ + patch("builtins.open", mock_open(read_data="{}")), \ + patch("facet_detection.json.load", side_effect=[abilities, hero_data]): + result = facet_detection.get_hero_abilities("Nonexistent Hero") + assert result is None + + +# --------------------------------------------------------------------------- # +# extract_facet_region — team corner branch + None guard +# --------------------------------------------------------------------------- # +def test_extract_facet_region_none_portrait_returns_none(): + assert facet_detection.extract_facet_region(None, "Radiant") is None + + +def _recording_portrait(): + """A fake portrait whose .shape feeds the bounds math and whose __getitem__ + records the (y, x) slice the extractor used, so we can assert the corner.""" + captured = {} + + class FakePortrait: + shape = (72, 108, 3) + + def __getitem__(self, key): + captured["yx"] = key + region = MagicMock() + region.shape = (28, 28, 3) # color -> triggers the stubbed cvtColor path + return region + + return FakePortrait(), captured + + +def test_extract_facet_region_radiant_uses_top_left_corner(): + portrait, captured = _recording_portrait() + facet_detection.extract_facet_region(portrait, "Radiant") + _, x_slice = captured["yx"] + assert x_slice.start == facet_detection.FACET_SIDE_MARGIN # left corner for Radiant + + +def test_extract_facet_region_dire_uses_right_corner(): + portrait, captured = _recording_portrait() + facet_detection.extract_facet_region(portrait, "Dire") + _, x_slice = captured["yx"] + # Dire pulls from the right side, so x start is well into the portrait width. + assert x_slice.start > 0 diff --git a/packages/clip-processor-py/tests/test_postgresql_client.py b/packages/clip-processor-py/tests/test_postgresql_client.py new file mode 100644 index 000000000..55c4a14e5 --- /dev/null +++ b/packages/clip-processor-py/tests/test_postgresql_client.py @@ -0,0 +1,352 @@ +"""Characterization tests for PostgresClient queue/result logic. + +All DB I/O is mocked via the `db_client`/`mock_cursor` fixtures (see conftest). +These pin: + - the multi-query dispatch in check_for_match_processing (before a UNION rewrite) + - the per-status branch in update_queue_status (before status-string constants) + - the JSON (de)serialization round-trip in save/get clip result +""" + +import json +from unittest.mock import MagicMock + +import pytest + + +# --------------------------------------------------------------------------- # +# check_for_match_processing — query dispatch order +# --------------------------------------------------------------------------- # +def test_match_processing_completed_short_circuits(db_client, mock_cursor): + mock_cursor.fetchone.side_effect = [{"clip_id": "c1"}] + out = db_client.check_for_match_processing("m1") + assert out == {"found": True, "status": "completed", "clip_id": "c1"} + assert mock_cursor.execute.call_count == 1 # stops at the first query + + +def test_match_processing_returns_active_queue_entry(db_client, mock_cursor): + mock_cursor.fetchone.side_effect = [ + None, # no completed result + {"request_id": "r1", "clip_id": "c1", "status": "processing"}, + ] + out = db_client.check_for_match_processing("m1") + assert out == {"found": True, "status": "processing", "clip_id": "c1", "request_id": "r1"} + assert mock_cursor.execute.call_count == 2 + + +def test_match_processing_returns_failed(db_client, mock_cursor): + mock_cursor.fetchone.side_effect = [None, None, {"request_id": "r1", "clip_id": "c1"}] + out = db_client.check_for_match_processing("m1") + assert out == {"found": True, "status": "failed", "clip_id": "c1", "request_id": "r1"} + assert mock_cursor.execute.call_count == 3 + + +def test_match_processing_returns_draft(db_client, mock_cursor): + mock_cursor.fetchone.side_effect = [None, None, None, {"clip_id": "c1"}] + out = db_client.check_for_match_processing("m1") + assert out == {"found": True, "status": "draft", "clip_id": "c1"} + assert mock_cursor.execute.call_count == 4 + + +def test_match_processing_not_found(db_client, mock_cursor): + mock_cursor.fetchone.side_effect = [None, None, None, None] + out = db_client.check_for_match_processing("m1") + assert out == {"found": False} + assert mock_cursor.execute.call_count == 4 + + +# --------------------------------------------------------------------------- # +# update_queue_status — per-status branch +# --------------------------------------------------------------------------- # +def test_update_status_processing_sets_started_at(db_client, mock_cursor): + assert db_client.update_queue_status("r1", "processing") is True + assert mock_cursor.execute.call_count == 1 + query = mock_cursor.execute.call_args_list[0].args[0] + assert "started_at" in query + db_client._mock_conn.commit.assert_called_once() + + +def test_update_status_completed_also_recomputes_positions(db_client, mock_cursor): + assert db_client.update_queue_status("r1", "completed", result_id="c1") is True + # main update + the pending-position recompute + assert mock_cursor.execute.call_count == 2 + first_query = mock_cursor.execute.call_args_list[0].args[0] + second_query = mock_cursor.execute.call_args_list[1].args[0] + assert "completed_at" in first_query and "result_id" in first_query + assert "position" in second_query + + +def test_update_status_failed_recomputes_positions(db_client, mock_cursor): + assert db_client.update_queue_status("r1", "failed") is True + assert mock_cursor.execute.call_count == 2 + + +def test_update_status_other_is_bare_update(db_client, mock_cursor): + assert db_client.update_queue_status("r1", "pending") is True + assert mock_cursor.execute.call_count == 1 + query = mock_cursor.execute.call_args_list[0].args[0] + assert "started_at" not in query and "completed_at" not in query + + +def test_update_status_rolls_back_on_error(db_client, mock_cursor): + mock_cursor.execute.side_effect = RuntimeError("boom") + assert db_client.update_queue_status("r1", "processing") is False + db_client._mock_conn.rollback.assert_called_once() + + +# --------------------------------------------------------------------------- # +# save_clip_result / get_clip_result — JSON round-trip +# --------------------------------------------------------------------------- # +def test_save_clip_result_serializes_result_and_omits_empty_facets(db_client, mock_cursor): + result = {"players": [{"player_name": "x", "team": "Radiant", "position": 0}], "heroes": []} + assert db_client.save_clip_result("c1", "u", result) is True + params = mock_cursor.execute.call_args.args[1] + assert params[0] == "c1" + assert json.loads(params[2]) == result # results column = json.dumps(result) + assert params[5] is None # no per-player facet -> facets column is NULL + + +def test_save_clip_result_extracts_facets(db_client, mock_cursor): + result = {"players": [{"player_name": "x", "team": "Dire", "position": 2, "facet": 1}]} + assert db_client.save_clip_result("c1", "u", result) is True + params = mock_cursor.execute.call_args.args[1] + facets = json.loads(params[5]) + assert facets["dire"] == [{"position": 2, "facet": 1}] + assert facets["radiant"] == [] + + +def test_get_clip_result_returns_results_payload(db_client, mock_cursor): + payload = {"players": [{"player_name": "x", "team": "Radiant", "position": 0}]} + mock_cursor.fetchone.return_value = {"results": payload, "facets": None} + assert db_client.get_clip_result("c1") == payload + + +def test_get_clip_result_merges_facets_into_players(db_client, mock_cursor): + payload = {"players": [{"player_name": "x", "team": "Radiant", "position": 0}]} + facets = {"radiant": [{"position": 0, "facet": 7}], "dire": []} + mock_cursor.fetchone.return_value = {"results": payload, "facets": facets} + out = db_client.get_clip_result("c1") + assert out["players"][0]["facet"] == 7 + + +def test_get_clip_result_returns_none_when_missing(db_client, mock_cursor): + mock_cursor.fetchone.return_value = None + assert db_client.get_clip_result("missing") is None + + +# --------------------------------------------------------------------------- # +# Queue lifecycle (ported from the old test/test_queue.py) +# --------------------------------------------------------------------------- # +def test_add_to_queue_inserts_and_returns_position(db_client, mock_cursor): + # add_to_queue consumes fetchone in this order: two schema-column checks, + # the dedup lookup, the pending-count, the avg-time query, then RETURNING *. + mock_cursor.fetchone.side_effect = [ + None, # match_id column check -> absent + None, # only_draft column check -> absent + None, # no existing duplicate + {"count": 2}, # pending count -> position 3 + [15.0], # get_average_processing_time + {"request_id": "row-id", "status": "pending", "position": 3}, # RETURNING * + ] + request_id, queue_info = db_client.add_to_queue( + request_type="clip", clip_id="test-clip", clip_url="https://clips.twitch.tv/test-clip" + ) + assert request_id is not None + assert queue_info.get("status") == "pending" + assert queue_info.get("position") == 3 + db_client._mock_conn.commit.assert_called_once() + + +@pytest.mark.parametrize( + "fetched, expected", + [ + ([10.5], 10.5), + ([None], 15.0), # default when no data + ([20.3], 20.3), + ], +) +def test_get_average_processing_time(db_client, mock_cursor, fetched, expected): + mock_cursor.fetchone.return_value = fetched + assert db_client.get_average_processing_time("clip") == expected + + +def test_get_next_pending_request_returns_row(db_client, mock_cursor): + mock_cursor.fetchone.return_value = { + "request_id": "test-id", "clip_id": "test-clip", "status": "pending", "position": 1 + } + request = db_client.get_next_pending_request() + assert request["request_id"] == "test-id" + assert request["status"] == "pending" + + +def test_get_next_pending_request_returns_none_when_empty(db_client, mock_cursor): + mock_cursor.fetchone.return_value = None + assert db_client.get_next_pending_request() is None + + +def test_add_to_queue_returns_existing_duplicate(db_client, mock_cursor): + mock_cursor.fetchone.return_value = { + "request_id": "existing-id", + "clip_id": "test-clip", + "status": "pending", + "position": 3, + "estimated_wait_seconds": 45, + } + request_id, queue_info = db_client.add_to_queue( + request_type="clip", clip_id="test-clip", clip_url="https://clips.twitch.tv/test-clip" + ) + assert request_id == "existing-id" + assert queue_info["status"] == "pending" + assert queue_info["position"] == 3 + + +def test_is_request_in_queue_finds_existing(db_client, mock_cursor): + mock_cursor.fetchone.return_value = {"request_id": "existing-id", "status": "pending"} + assert db_client.is_request_in_queue("clip", "test-clip")["request_id"] == "existing-id" + + +def test_is_request_in_queue_returns_none_when_absent(db_client, mock_cursor): + mock_cursor.fetchone.return_value = None + assert db_client.is_request_in_queue("clip", "non-existent-clip") is None + + +# --------------------------------------------------------------------------- # +# get_clip_result_by_match_id +# --------------------------------------------------------------------------- # +def test_match_result_prefers_non_draft_and_attaches_clip_meta(db_client, mock_cursor): + db_client.get_latest_draft_for_match = MagicMock(return_value=None) + mock_cursor.fetchone.return_value = { + "clip_id": "c1", + "clip_url": "u1", + "results": {"is_draft": False, "players": [{"player_name": "x"}]}, + "facets": None, + } + out = db_client.get_clip_result_by_match_id("m1") + assert out["clip_id"] == "c1" + assert out["clip_url"] == "u1" + assert out["is_draft"] is False + + +def test_match_result_merges_facets_into_players_and_heroes(db_client, mock_cursor): + # players are 1-indexed, heroes are 0-indexed; facets are keyed by the + # 1-indexed (player) position. The heroes loop adds +1 to realign, so the + # SAME facet must land on both the player and its corresponding hero. + db_client.get_latest_draft_for_match = MagicMock(return_value=None) + mock_cursor.fetchone.return_value = { + "clip_id": "c1", + "clip_url": "u1", + "results": { + "is_draft": False, + "players": [{"player_name": "x", "team": "Radiant", "position": 1}], + "heroes": [{"name": "h", "team": "Radiant", "position": 0}], + }, + "facets": {"radiant": [{"position": 1, "facet": 7}], "dire": []}, + } + out = db_client.get_clip_result_by_match_id("m1") + assert out["players"][0]["facet"] == 7 + assert out["heroes"][0]["facet"] == 7 # +1 realignment lands on the same facet + + +def test_match_result_attaches_draft_info(db_client, mock_cursor): + db_client.get_latest_draft_for_match = MagicMock( + return_value={"is_draft": True, "captains": {"Radiant": "Cap"}, "draft_player_order": ["a"]} + ) + mock_cursor.fetchone.return_value = { + "clip_id": "c1", "clip_url": "u1", + "results": {"is_draft": False, "players": []}, "facets": None, + } + out = db_client.get_clip_result_by_match_id("m1") + assert out["draft_info"]["captains"] == {"Radiant": "Cap"} + assert out["draft_info"]["draft_player_order"] == ["a"] + + +def test_match_result_falls_back_to_draft_when_no_non_draft(db_client, mock_cursor): + # First query (non-draft) returns nothing; fallback draft query returns a row. + mock_cursor.fetchone.side_effect = [ + None, + {"clip_id": "cd", "clip_url": "ud", "results": {"is_draft": True, "players": []}}, + ] + out = db_client.get_clip_result_by_match_id("m1") + assert out["clip_id"] == "cd" + assert out["is_draft"] is True + + +def test_match_result_returns_none_when_nothing_found(db_client, mock_cursor): + mock_cursor.fetchone.side_effect = [None, None] + assert db_client.get_clip_result_by_match_id("m1") is None + + +def test_match_result_survives_facets_missing_a_team_key(db_client, mock_cursor): + # BUG TARGET #1: stored facets dict is missing the 'dire' key (legacy/partial + # payload), and a Dire player is present. The merge does `facets['dire']` + # which raises KeyError; the broad except swallows it and the whole cached + # read returns None -> silent cache miss -> needless reprocessing in prod. + # Correct behavior: still return the cached result. + db_client.get_latest_draft_for_match = MagicMock(return_value=None) + mock_cursor.fetchone.return_value = { + "clip_id": "c1", "clip_url": "u1", + "results": { + "is_draft": False, + "players": [{"player_name": "x", "team": "Dire", "position": 2}], + }, + "facets": {"radiant": [{"position": 1, "facet": 7}]}, # no 'dire' key + } + out = db_client.get_clip_result_by_match_id("m1") + assert out is not None + assert out["clip_id"] == "c1" + + +# --------------------------------------------------------------------------- # +# get_latest_draft_for_match +# --------------------------------------------------------------------------- # +def test_latest_draft_returns_results_with_clip_meta(db_client, mock_cursor): + mock_cursor.fetchone.return_value = { + "clip_id": "cd", "clip_url": "ud", "results": {"is_draft": True, "players": []}, + } + out = db_client.get_latest_draft_for_match("m1") + assert out["clip_id"] == "cd" + assert out["is_draft"] is True + + +def test_latest_draft_returns_none_when_absent(db_client, mock_cursor): + mock_cursor.fetchone.return_value = None + assert db_client.get_latest_draft_for_match("m1") is None + + +# --------------------------------------------------------------------------- # +# is_queue_processing +# --------------------------------------------------------------------------- # +def test_is_queue_processing_true_when_count_positive(db_client, mock_cursor): + mock_cursor.fetchone.return_value = [1] + assert db_client.is_queue_processing() is True + + +def test_is_queue_processing_false_when_zero(db_client, mock_cursor): + mock_cursor.fetchone.return_value = [0] + assert db_client.is_queue_processing() is False + + +# --------------------------------------------------------------------------- # +# get_clip_result robustness (BUG TARGET #1) +# --------------------------------------------------------------------------- # +def test_get_clip_result_survives_facets_missing_team_key(db_client, mock_cursor): + # Same silent-cache-miss robustness gap as the match_id path: a facets dict + # lacking the player's team key raises KeyError, swallowed -> returns None. + mock_cursor.fetchone.return_value = { + "results": {"players": [{"player_name": "x", "team": "Dire", "position": 2}]}, + "facets": {"radiant": [{"position": 1, "facet": 7}]}, # no 'dire' key + } + out = db_client.get_clip_result("c1") + assert out is not None + assert out["players"][0]["player_name"] == "x" + + +def test_get_clip_result_survives_player_missing_position(db_client, mock_cursor): + # A player dict missing 'position'/'team' (partial detection) also raises + # KeyError during facet merge and silently nukes the cached result. + mock_cursor.fetchone.return_value = { + "results": {"players": [{"player_name": "x"}]}, # no team/position + "facets": {"radiant": [{"position": 1, "facet": 7}], "dire": []}, + } + out = db_client.get_clip_result("c1") + assert out is not None + assert out["players"][0]["player_name"] == "x" diff --git a/packages/clip-processor-py/tests/test_stream_processor.py b/packages/clip-processor-py/tests/test_stream_processor.py new file mode 100644 index 000000000..b98985753 --- /dev/null +++ b/packages/clip-processor-py/tests/test_stream_processor.py @@ -0,0 +1,123 @@ +"""Tests for StreamManager state management and per-stream error backoff. + +cv2/numpy/streamlink are stubbed offline (see conftest). We never start the +asyncio loop; instead we construct a manager and drive its pure dict-state +methods plus a single `_process_stream` iteration with `_capture_frame`/ +`_process_frame` mocked. +""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from stream_processor import StreamManager, StreamStatus + + +@pytest.fixture +def manager(): + mgr = StreamManager(capture_interval=3, max_concurrent=10, quality="720p") + yield mgr + mgr.executor.shutdown(wait=False) + + +# --------------------------------------------------------------------------- # +# add_stream / remove_stream / update_priority +# --------------------------------------------------------------------------- # +def test_add_stream_initializes_state(manager): + manager.add_stream("streamer", priority=4) + s = manager.streams["streamer"] + assert s["status"] == StreamStatus.PENDING + assert s["priority"] == 4 + assert s["captures"] == 0 + assert s["frame_paths"] == [] + + +def test_add_stream_dedups_and_updates_priority(manager): + manager.add_stream("streamer", priority=5) + manager.add_stream("streamer", priority=1) + assert len(manager.streams) == 1 + assert manager.streams["streamer"]["priority"] == 1 + + +def test_remove_stream_drops_from_active(manager): + manager.add_stream("streamer") + manager.active_streams.add("streamer") + manager.remove_stream("streamer") + assert "streamer" not in manager.streams + assert "streamer" not in manager.active_streams + + +def test_update_priority_noop_for_unknown(manager): + manager.update_priority("ghost", 2) # must not raise / create + assert "ghost" not in manager.streams + + +# --------------------------------------------------------------------------- # +# get_stats / get_stream_status / get_all_streams +# --------------------------------------------------------------------------- # +def test_get_stats_success_rate_math(manager): + manager.add_stream("a") + manager.stats["total_captures"] = 4 + manager.stats["successful_captures"] = 3 + stats = manager.get_stats() + assert stats["streams"] == 1 + assert stats["success_rate"] == 75.0 + + +def test_get_stats_zero_capture_guard(manager): + stats = manager.get_stats() + assert stats["success_rate"] == 0 # no divide-by-zero + + +def test_get_stream_status_returns_copy_or_none(manager): + manager.add_stream("a") + status = manager.get_stream_status("a") + status["priority"] = 999 # mutate the copy + assert manager.streams["a"]["priority"] != 999 # original untouched + assert manager.get_stream_status("missing") is None + + +def test_get_all_streams_returns_all(manager): + manager.add_stream("a") + manager.add_stream("b") + assert set(manager.get_all_streams()) == {"a", "b"} + + +# --------------------------------------------------------------------------- # +# _process_stream — error backoff vs success reset +# --------------------------------------------------------------------------- # +def test_process_stream_failure_increments_consecutive_errors(manager): + manager.add_stream("a") + manager.running = False # so the finally block does not requeue on the loop + with patch.object(manager, "_capture_frame", AsyncMock(return_value=None)): + asyncio.run(manager._process_stream("a")) + s = manager.streams["a"] + assert s["consecutive_errors"] == 1 + assert s["status"] == StreamStatus.ERROR + assert manager.stats["failed_captures"] == 1 + assert "a" not in manager.active_streams + + +def test_process_stream_success_resets_errors_and_records_frame(manager): + manager.add_stream("a") + manager.running = False + manager.streams["a"]["consecutive_errors"] = 3 # pre-existing error streak + with patch.object(manager, "_capture_frame", AsyncMock(return_value="/tmp/a.jpg")), \ + patch.object(manager, "_process_frame", AsyncMock(return_value=False)): + asyncio.run(manager._process_stream("a")) + s = manager.streams["a"] + assert s["consecutive_errors"] == 0 + assert s["status"] == StreamStatus.ONLINE + assert s["frame_paths"] == ["/tmp/a.jpg"] + assert manager.stats["successful_captures"] == 1 + + +def test_process_stream_counts_dota_match(manager): + manager.add_stream("a") + manager.running = False + with patch.object(manager, "_capture_frame", AsyncMock(return_value="/tmp/a.jpg")), \ + patch.object(manager, "_process_frame", AsyncMock(return_value=True)): + asyncio.run(manager._process_stream("a")) + assert manager.streams["a"]["dota_matches"] == 1 + assert manager.stats["dota_matches_found"] == 1 diff --git a/packages/clip-processor-py/tests/test_stream_utils.py b/packages/clip-processor-py/tests/test_stream_utils.py new file mode 100644 index 000000000..d5c8e35d7 --- /dev/null +++ b/packages/clip-processor-py/tests/test_stream_utils.py @@ -0,0 +1,66 @@ +"""Characterization tests for stream_utils.capture_frame_from_stream. + +Pins the retry loop, frame-skip, "preparing screen" early-exit, and capture +release behavior before the retry logic is extracted into a shared helper. +cv2/streamlink are stubbed offline (see conftest); we patch the few seams the +function actually drives. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +import stream_utils + + +def _capture(read_results, opened=True): + """A mock cv2.VideoCapture whose .read() yields the given (ok, frame) tuples.""" + cap = MagicMock() + cap.isOpened.return_value = opened + cap.read.side_effect = list(read_results) + return cap + + +@pytest.fixture(autouse=True) +def _no_sleep_and_tmp_frames(tmp_path): + with patch.object(stream_utils, "TEMP_DIR", tmp_path), \ + patch.object(stream_utils.time, "sleep"): + yield + + +def test_returns_frame_path_on_clean_capture(): + cap = _capture([(True, "frame")]) + with patch.object(stream_utils, "get_stream_url", return_value="http://s"), \ + patch.object(stream_utils.cv2, "VideoCapture", return_value=cap), \ + patch.object(stream_utils, "is_preparing_screen", return_value=False): + result = stream_utils.capture_frame_from_stream( + "streamer", max_retries=1, frames_to_skip=0 + ) + assert result is not None + assert result.endswith(".jpg") + cap.release.assert_called_once() + + +def test_returns_none_when_stream_url_unavailable(): + with patch.object(stream_utils, "get_stream_url", return_value=None) as get_url, \ + patch.object(stream_utils.cv2, "VideoCapture") as vc: + result = stream_utils.capture_frame_from_stream( + "streamer", max_retries=3, frames_to_skip=0 + ) + assert result is None + assert get_url.call_count == 3 # retried for each attempt + vc.assert_not_called() # never tried to open a capture + + +def test_retries_and_releases_on_preparing_screen(): + # Every read succeeds but every frame is a "preparing your stream" screen. + cap = _capture([(True, "frame")] * 10) + with patch.object(stream_utils, "get_stream_url", return_value="http://s"), \ + patch.object(stream_utils.cv2, "VideoCapture", return_value=cap), \ + patch.object(stream_utils, "is_preparing_screen", return_value=True): + result = stream_utils.capture_frame_from_stream( + "streamer", max_retries=2, frames_to_skip=0 + ) + assert result is None + # capture released once per retry + assert cap.release.call_count == 2 diff --git a/packages/clip-processor-py/uv.lock b/packages/clip-processor-py/uv.lock new file mode 100644 index 000000000..627e2f795 --- /dev/null +++ b/packages/clip-processor-py/uv.lock @@ -0,0 +1,433 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[manifest] + +[manifest.dependency-groups] +dev = [ + { name = "beautifulsoup4" }, + { name = "flask" }, + { name = "pillow" }, + { name = "psutil" }, + { name = "psycopg2-binary" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "requests" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] diff --git a/packages/dota/src/dota/events/gsi-events/newdata.ts b/packages/dota/src/dota/events/gsi-events/newdata.ts index fdcc3a086..69286f97a 100644 --- a/packages/dota/src/dota/events/gsi-events/newdata.ts +++ b/packages/dota/src/dota/events/gsi-events/newdata.ts @@ -35,7 +35,8 @@ import { isSpectator } from '../../lib/isSpectator' import { say } from '../../say' import { server } from '../../server' import eventHandler from '../EventHandler' -import { minimapParser } from '../minimap/parser' +// minimap overlay is unused in prod — disabled to skip per-tick parse; revive by uncommenting +// import { minimapParser } from '../minimap/parser' import { selectNewEvents } from './selectNewEvents' import { sendExtensionPubSubBroadcastMessageIfChanged } from './sendExtensionPubSubBroadcastMessageIfChanged' @@ -672,15 +673,16 @@ eventHandler.registerEvent('newdata', { return } + // minimap overlay is unused in prod — disabled to skip per-tick parse; revive by uncommenting // only if they're in a match ^ and they're a beta tester - if (dotaClient.client.beta_tester && dotaClient.client.stream_online) { - const enabled = getValueOrDefault( - DBSettings['minimap-blocker'], - dotaClient.client.settings, - dotaClient.client.subscription, - ) - if (enabled) minimapParser.init(data, dotaClient.mapBlocker) - } + // if (dotaClient.client.beta_tester && dotaClient.client.stream_online) { + // const enabled = getValueOrDefault( + // DBSettings['minimap-blocker'], + // dotaClient.client.settings, + // dotaClient.client.subscription, + // ) + // if (enabled) minimapParser.init(data, dotaClient.mapBlocker) + // } // Can't just !dotaClient.heroSlot because it can be 0 const purchaser = dotaClient.client.gsi?.items?.teleport0?.purchaser diff --git a/packages/dota/src/dota/lib/__tests__/ranks.test.ts b/packages/dota/src/dota/lib/__tests__/ranks.test.ts new file mode 100644 index 000000000..86efdea2f --- /dev/null +++ b/packages/dota/src/dota/lib/__tests__/ranks.test.ts @@ -0,0 +1,91 @@ +// Tests the pure rank-math helpers in ranks.ts. We only assert the functions +// NOT overridden by twitch/lib/__tests__/setupMocks.ts (which process-wide +// mock.module's ranks to stub getRankTitle/getRankDescription/getOpenDotaProfile). +// rankTierToMmr/mmrToRankTier/estimateMMR/getRankDetail are preserved real via +// that harness's spread, so they're stable no matter the suite run order. +import { describe, expect, it, mock } from 'bun:test' +import { buildSharedUtilsMock } from '../../../__tests__/sharedMocks.ts' + +const noopLogger = { + info: () => undefined, + error: () => undefined, + warn: () => undefined, + debug: () => undefined, +} + +// ranks.ts -> getWL imports `supabase`/`logger` from shared-utils at load time; +// these helpers never touch it at runtime, so a no-op surface is enough. +mock.module('@dotabod/shared-utils', () => + buildSharedUtilsMock({ supabase: {}, logger: noopLogger }), +) + +const { rankTierToMmr, mmrToRankTier, estimateMMR, getRankDetail } = await import('../ranks.ts') + +describe('mmrToRankTier', () => { + it('returns 0 (uncalibrated) for non-positive mmr', () => { + expect(mmrToRankTier(0)).toBe(0) + expect(mmrToRankTier(-50)).toBe(0) + }) + + it('returns 80 (immortal) at or above the highest rank mmr', () => { + expect(mmrToRankTier(5619)).toBe(80) + expect(mmrToRankTier(9000)).toBe(80) + }) + + it('maps mmr into the medal*10+stars tier', () => { + expect(mmrToRankTier(100)).toBe(11) // Herald 1 + expect(mmrToRankTier(3080)).toBe(51) // Legend 1 + expect(mmrToRankTier(5000)).toBe(72) // Divine 2 + }) +}) + +describe('rankTierToMmr', () => { + it('returns 0 for falsy / zero rank tiers', () => { + expect(rankTierToMmr(0)).toBe(0) + expect(rankTierToMmr('')).toBe(0) + }) + + it('returns 6000 for immortal rank tiers above 77', () => { + expect(rankTierToMmr(80)).toBe(6000) + }) + + it('returns the midpoint of the rank range', () => { + expect(rankTierToMmr(11)).toBe((0 + 153) / 2) // Herald 1 + expect(rankTierToMmr(15)).toBe((616 + 769) / 2) // Herald 5 + }) + + it('floors stars above 5 to 5', () => { + // rank tier 18 -> medal 1, stars capped at 5 -> same as Herald 5 + expect(rankTierToMmr(18)).toBe(rankTierToMmr(15)) + }) +}) + +describe('estimateMMR', () => { + it('returns 8500 for out-of-range leaderboard ranks', () => { + expect(estimateMMR(0, 'EUROPE')).toBe(8500) + expect(estimateMMR(5001, 'EUROPE')).toBe(8500) + }) + + it('computes region-specific base mmr (ln(1)=0 makes rank 1 the base constant)', () => { + expect(estimateMMR(1, 'EUROPE')).toBe(15300) + expect(estimateMMR(1, 'US EAST')).toBe(14900) + expect(estimateMMR(1, 'BRAZIL')).toBe(14150) + }) +}) + +describe('getRankDetail', () => { + it('returns null for non-positive mmr', async () => { + expect(await getRankDetail(0)).toBeNull() + expect(await getRankDetail(-10)).toBeNull() + }) + + it('returns rank progression details for an in-range mmr', async () => { + const detail = await getRankDetail(100) + expect(detail).not.toBeNull() + const d = detail as Exclude + expect((d as any).myRank.title).toBe('Herald☆1') + expect((d as any).nextMMR).toBe(154) + expect((d as any).mmrToNextRank).toBe(54) + expect((d as any).winsToNextRank).toBe(3) // ceil(54 / 25) + }) +}) diff --git a/packages/dota/src/twitch/commands/avg.ts b/packages/dota/src/twitch/commands/avg.ts index 8b46176a7..caf46254f 100644 --- a/packages/dota/src/twitch/commands/avg.ts +++ b/packages/dota/src/twitch/commands/avg.ts @@ -2,15 +2,14 @@ import { t } from 'i18next' import { calculateAvg } from '../../dota/lib/calculateAvg' import { getAccountsFromMatch } from '../../dota/lib/getAccountsFromMatch' -import { DBSettings, ENABLE_SPECTATE_FRIEND_GAME } from '../../settings' -import { is8500Plus } from '../../utils/index' +import { DBSettings } from '../../settings' import { chatClient } from '../chatClient' import commandHandler from '../lib/CommandHandler' commandHandler.registerCommand('avg', { onlyOnline: true, dbkey: DBSettings.commandAvg, - handler: async (message, args) => { + handler: async (message) => { const { channel: { client }, } = message @@ -38,15 +37,7 @@ commandHandler.registerCommand('avg', { players: matchPlayers, }) .then((avg) => { - const append = - !ENABLE_SPECTATE_FRIEND_GAME || is8500Plus(client) - ? ` · ${t('matchDataValveDisabled', { emote: 'PoroSad', lng: client.locale })}` - : '' - chatClient.say( - message.channel.name, - `${avg}${avgDescriptor}${append}`, - message.user.messageId, - ) + chatClient.say(message.channel.name, `${avg}${avgDescriptor}`, message.user.messageId) }) .catch((e) => { chatClient.say( diff --git a/packages/dota/src/twitch/commands/gm.ts b/packages/dota/src/twitch/commands/gm.ts index bbc2fedd5..15e5888f8 100644 --- a/packages/dota/src/twitch/commands/gm.ts +++ b/packages/dota/src/twitch/commands/gm.ts @@ -1,9 +1,8 @@ import { t } from 'i18next' import { getAccountsFromMatch } from '../../dota/lib/getAccountsFromMatch' -import { DBSettings, ENABLE_SPECTATE_FRIEND_GAME } from '../../settings' +import { DBSettings } from '../../settings' import { gameMedals } from '../../steam/medals' -import { is8500Plus } from '../../utils/index' import { chatClient } from '../chatClient' import commandHandler from '../lib/CommandHandler' @@ -11,7 +10,7 @@ commandHandler.registerCommand('gm', { aliases: ['medals', 'ranks'], onlyOnline: true, dbkey: DBSettings.commandGM, - handler: async (message, args) => { + handler: async (message) => { const { channel: { client }, } = message @@ -33,17 +32,7 @@ commandHandler.registerCommand('gm', { gameMedals(client.locale, message.channel.client.gsi?.map?.matchid, matchPlayers) .then((desc) => { - let append = '' - if ( - !ENABLE_SPECTATE_FRIEND_GAME || - (matchPlayers.length === 1 && - matchPlayers[0].accountid === client.steam32Id && - is8500Plus(client)) - ) { - append = ` · ${t('matchDataValveDisabled', { emote: 'PoroSad', lng: message.channel.client.locale })}` - } - - chatClient.say(message.channel.name, desc + append, message.user.messageId) + chatClient.say(message.channel.name, desc, message.user.messageId) }) .catch((e) => { chatClient.say( diff --git a/packages/dota/src/twitch/commands/np.ts b/packages/dota/src/twitch/commands/np.ts index a3eb491f8..20687424e 100644 --- a/packages/dota/src/twitch/commands/np.ts +++ b/packages/dota/src/twitch/commands/np.ts @@ -2,11 +2,10 @@ import { moderateText } from '@dotabod/profanity-filter' import { logger } from '@dotabod/shared-utils' import { t } from 'i18next' import { getAccountsFromMatch } from '../../dota/lib/getAccountsFromMatch' -import { DBSettings, ENABLE_SPECTATE_FRIEND_GAME, getValueOrDefault } from '../../settings' +import { DBSettings, getValueOrDefault } from '../../settings' import MongoDBSingleton from '../../steam/MongoDBSingleton' import type { NotablePlayers } from '../../steam/notableplayers' import { notablePlayers } from '../../steam/notableplayers' -import { is8500Plus } from '../../utils/index' import { chatClient } from '../chatClient' import commandHandler from '../lib/CommandHandler' @@ -150,16 +149,7 @@ commandHandler.registerCommand('np', { steam32Id: client.steam32Id, }) .then((desc) => { - let append = '' - if ( - !ENABLE_SPECTATE_FRIEND_GAME || - (matchPlayers.length === 1 && - matchPlayers[0].accountid === client.steam32Id && - is8500Plus(client)) - ) { - append = ` · ${t('matchDataValveDisabled', { emote: 'PoroSad', lng: message.channel.client.locale })}` - } - chatClient.say(channel, desc.description + append, message.user.messageId) + chatClient.say(channel, desc.description, message.user.messageId) }) .catch((e) => { chatClient.say( diff --git a/packages/dota/src/twitch/commands/smurfs.ts b/packages/dota/src/twitch/commands/smurfs.ts index bded08f6d..308bb07d9 100644 --- a/packages/dota/src/twitch/commands/smurfs.ts +++ b/packages/dota/src/twitch/commands/smurfs.ts @@ -1,9 +1,8 @@ import { t } from 'i18next' import { getAccountsFromMatch } from '../../dota/lib/getAccountsFromMatch' -import { DBSettings, ENABLE_SPECTATE_FRIEND_GAME } from '../../settings' +import { DBSettings } from '../../settings' import { smurfs } from '../../steam/smurfs' -import { is8500Plus } from '../../utils/index' import { chatClient } from '../chatClient' import commandHandler from '../lib/CommandHandler' @@ -11,7 +10,7 @@ commandHandler.registerCommand('smurfs', { aliases: ['lifetimes', 'totals', 'games', 'smurf'], onlyOnline: true, dbkey: DBSettings.commandSmurfs, - handler: async (message, args) => { + handler: async (message) => { const { channel: { client }, } = message @@ -32,14 +31,9 @@ commandHandler.registerCommand('smurfs', { const { matchPlayers } = await getAccountsFromMatch({ gsi: client.gsi }) - const append = - !ENABLE_SPECTATE_FRIEND_GAME || is8500Plus(client) - ? ` · ${t('matchDataValveDisabled', { emote: 'PoroSad', lng: client.locale })}` - : '' - smurfs(client.locale, message.channel.client.gsi?.map?.matchid, matchPlayers) .then((desc) => { - chatClient.say(message.channel.name, desc + append, message.user.messageId) + chatClient.say(message.channel.name, desc, message.user.messageId) }) .catch((e) => { chatClient.say( diff --git a/packages/dota/src/twitch/lib/__tests__/accountsCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/accountsCommands.integration.test.ts new file mode 100644 index 000000000..bf4fc7d6c --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/accountsCommands.integration.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// gm/np/smurfs/lg call getAccountsFromMatch (owned by gsiMocks) only after an +// early steam32Id guard, so we cover that collision-safe guard branch here. +// geo short-circuits to the Valve-disabled message before getAccountsFromMatch, +// so its reachable paths are fully covered. + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() +}) + +for (const cmd of ['gm', 'np', 'smurfs', 'lg']) { + describe(`!${cmd}`, () => { + it('reports unknownSteam when there is no steam id', async () => { + await commandHandler.handleMessage( + makeMessage({ content: `!${cmd}`, clientOverrides: { steam32Id: null } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('steam') + }) + + it('reports the multiAccount message when no steam id and multiAccount is set', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: `!${cmd}`, + clientOverrides: { steam32Id: null, multiAccount: true } as any, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('dotabod.com/dashboard/features') + }) + }) +} + +describe('!geo', () => { + it('reports notPlaying when there is no live match id', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!geo' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports the Valve-disabled message for a live non-spectator match', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!geo', + clientOverrides: { gsi: { map: { matchid: '7777777777' } } as any }, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('Valve disabled') + }) + + it('blocks viewers (permission below mod)', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!geo', permission: 0, userName: 'viewer' }), + ) + expect(state.chatSayCalls).toHaveLength(0) + }) +}) diff --git a/packages/dota/src/twitch/lib/__tests__/gsiCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/gsiCommands.integration.test.ts new file mode 100644 index 000000000..af23d15dd --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/gsiCommands.integration.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { getHeroNameOrColor } from '../../../dota/lib/heroes.ts' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// GSI-reading commands that format output from client.gsi (no DB/network). +// hero id 1 (Anti-Mage) has aghs_desc data, so the aghs/shard valid-hero paths +// always echo the hero name regardless of has_scepter/has_shard. +const HERO_ID = 1 +const heroName = getHeroNameOrColor(HERO_ID) + +const playingGsi = (extra: Record = {}) => + ({ map: { matchid: '7777777777' }, hero: { id: HERO_ID }, player: {}, ...extra }) as any + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() +}) + +describe('!xpm', () => { + it('blocks when the stream is offline', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!xpm', clientOverrides: { stream_online: false } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports 0 xpm when there is no GSI data', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!xpm' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('0') + }) + + it('reports the player xpm from GSI', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!xpm', + clientOverrides: { gsi: playingGsi({ player: { xpm: 742 } }) }, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('742') + }) +}) + +describe('!d2pt', () => { + it('chats a dota2protracker build URL with the hero name', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!d2pt', clientOverrides: { gsi: playingGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('dota2protracker.com/hero/') + expect(state.chatSayCalls[0].message).toContain(heroName) + }) +}) + +describe('!aghs', () => { + it('reports notPlaying when there is no live match', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!aghs' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports gameNotFound when in a match but the hero is invalid', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!aghs', + clientOverrides: { gsi: { map: { matchid: '7777777777' }, hero: { id: 0 } } as any }, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('game') + }) + + it('echoes the hero name for a valid hero in a live match', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!aghs', clientOverrides: { gsi: playingGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain(heroName) + }) +}) + +describe('!shard', () => { + it('reports notPlaying when there is no live match', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!shard' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('echoes the hero name for a valid hero in a live match', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!shard', clientOverrides: { gsi: playingGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain(heroName) + }) +}) + +describe('!facet', () => { + it('reports notPlaying when there is no live match', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!facet' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('produces a single chat reply for a valid hero in a live match', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!facet', + clientOverrides: { gsi: playingGsi({ hero: { id: HERO_ID, facet: 1 } }) }, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.length).toBeGreaterThan(0) + }) +}) + +describe('!innate', () => { + it('reports notPlaying when there is no live match', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!innate' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports gameNotFound when in a match but the hero is invalid', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!innate', + clientOverrides: { gsi: { map: { matchid: '7777777777' }, hero: { id: 0 } } as any }, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('game') + }) + + it('produces a single chat reply for a valid hero in a live match', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!innate', clientOverrides: { gsi: playingGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/dota/src/twitch/lib/__tests__/matchDataCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/matchDataCommands.integration.test.ts new file mode 100644 index 000000000..be2b1297b --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/matchDataCommands.integration.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { LOBBY_TYPE_RANKED } from '../../../db/getWL.ts' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// Commands that read live match data from the (mocked) MongoDB delayedGames +// collection via state.delayedGame. +const liveGsi = () => ({ map: { matchid: '7777777777' } }) as any + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() +}) + +describe('!spectators', () => { + it('blocks when the stream is offline', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!spectators', clientOverrides: { stream_online: false } }), + ) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports notPlaying when there is no live match id', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!spectators' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports missingMatchData when Mongo has no row for the match', async () => { + state.delayedGame = null + await commandHandler.handleMessage( + makeMessage({ content: '!spectators', clientOverrides: { gsi: liveGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports the spectator count from Mongo', async () => { + state.delayedGame = { spectators: 137 } + await commandHandler.handleMessage( + makeMessage({ content: '!spectators', clientOverrides: { gsi: liveGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('137') + }) +}) + +describe('!ranked', () => { + it('reports unknownSteam when there is no steam32Id', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!ranked', clientOverrides: { steam32Id: null } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('steam') + }) + + it('reports not-ranked for a non-match lobby id of 0', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!ranked', + clientOverrides: { gsi: { map: { matchid: '0' } } as any }, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('not ranked') + }) + + it('reports ranked yes when the Mongo lobby_type is ranked', async () => { + state.delayedGame = { match: { lobby_type: LOBBY_TYPE_RANKED } } + await commandHandler.handleMessage( + makeMessage({ content: '!ranked', clientOverrides: { gsi: liveGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + const msg = state.chatSayCalls[0].message.toLowerCase() + expect(msg).toContain('ranked') + expect(msg).not.toContain('not ranked') + }) + + it('reports ranked no when the Mongo lobby_type is unranked', async () => { + state.delayedGame = { match: { lobby_type: 0 } } + await commandHandler.handleMessage( + makeMessage({ content: '!ranked', clientOverrides: { gsi: liveGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('not ranked') + }) + + it('reports missingMatchData when Mongo has no row', async () => { + state.delayedGame = null + await commandHandler.handleMessage( + makeMessage({ content: '!ranked', clientOverrides: { gsi: liveGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) +}) diff --git a/packages/dota/src/twitch/lib/__tests__/matchStatsCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/matchStatsCommands.integration.test.ts new file mode 100644 index 000000000..a7d6cf438 --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/matchStatsCommands.integration.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// !items and !stats read live player data. Since ENABLE_SPECTATE_FRIEND_GAME +// is false (Valve disabled the live-spectate proto), the non-spectator path +// short-circuits to the "Valve disabled" message before touching Redis/steam. +const liveGsi = () => + ({ map: { matchid: '7777777777' }, player: { accountid: 99999 }, hero: { id: 1 } }) as any + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() +}) + +describe('!items', () => { + it('blocks when the stream is offline', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!items', clientOverrides: { stream_online: false } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports notPlaying when there is no live match id', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!items' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports gameNotFound for a non-numeric match id', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!items', + clientOverrides: { gsi: { map: { matchid: '0' } } as any }, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('game') + }) + + it('reports the Valve-disabled message for a live non-spectator match', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!items', clientOverrides: { gsi: liveGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('Valve disabled') + }) +}) + +describe('!stats', () => { + it('reports notPlaying when there is no live match id', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!stats' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('reports the Valve-disabled message for a live non-spectator match', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!stats', clientOverrides: { gsi: liveGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('Valve disabled') + }) + + it('routes the !kda alias to the same handler', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!kda', clientOverrides: { gsi: liveGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('Valve disabled') + }) +}) diff --git a/packages/dota/src/twitch/lib/__tests__/modCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/modCommands.integration.test.ts new file mode 100644 index 000000000..8b9c7116f --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/modCommands.integration.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { modMode } from '../../../dota/lib/consts.ts' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// Mod-only commands that drive updateMmr (mocked -> state.updateMmrCalls) and +// the Twitch chat settings API (mocked -> state.chatSettingsUpdates). + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() + modMode.clear() +}) + +describe('!setmmr', () => { + it('rejects an invalid mmr value', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!setmmr notanumber' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('mmr') + expect(state.updateMmrCalls).toHaveLength(0) + }) + + it('updates mmr for a single-account streamer', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!setmmr 4200' })) + expect(state.updateMmrCalls).toHaveLength(1) + expect(state.updateMmrCalls[0]).toMatchObject({ newMmr: '4200', steam32Id: 99999 }) + }) + + it('blocks viewers (permission below mod)', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!setmmr 4200', permission: 0, userName: 'viewer' }), + ) + expect(state.updateMmrCalls).toHaveLength(0) + expect(state.chatSayCalls).toHaveLength(0) + }) +}) + +describe('!modsonly', () => { + it('enables emote+sub-only mode and announces it on first use', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!modsonly' })) + expect(state.chatSettingsUpdates).toHaveLength(1) + expect(state.chatSettingsUpdates[0].settings).toEqual({ + emoteOnlyModeEnabled: true, + subscriberOnlyModeEnabled: true, + }) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('BASED Clap') + }) + + it('disables the mode on the second use (toggle off)', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!modsonly' })) + await commandHandler.handleMessage(makeMessage({ content: '!modsonly' })) + expect(state.chatSettingsUpdates).toHaveLength(2) + expect(state.chatSettingsUpdates[1].settings).toEqual({ + emoteOnlyModeEnabled: false, + subscriberOnlyModeEnabled: false, + }) + }) + + it('skips the Twitch settings call when the bot is banned', async () => { + state.botBanned = true + await commandHandler.handleMessage(makeMessage({ content: '!modsonly' })) + expect(state.chatSettingsUpdates).toHaveLength(0) + expect(state.chatSayCalls).toHaveLength(1) + }) +}) diff --git a/packages/dota/src/twitch/lib/__tests__/profileCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/profileCommands.integration.test.ts new file mode 100644 index 000000000..aebdd076f --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/profileCommands.integration.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// Profile-link family (opendota, profile) plus the broadcaster-only !friends. +const liveGsi = () => + ({ map: { matchid: '7777777777' }, player: { accountid: 99999 }, hero: { id: 1 } }) as any + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() +}) + +describe('!opendota', () => { + it('chats the broadcaster opendota URL with no args', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!opendota' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('opendota.com/players/99999') + }) + + it('falls back to notPlaying when there is no steam id and no live match', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!opendota', clientOverrides: { steam32Id: null } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('chats the player opendota URL from a live match when args are given', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!opendota me', clientOverrides: { gsi: liveGsi() } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('opendota.com/players/99999') + }) +}) + +describe('!profile', () => { + it('chats the broadcaster dotabuff URL with no args', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!profile' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('dotabuff.com/players/99999') + }) + + it('blocks when the stream is offline', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!profile', clientOverrides: { stream_online: false } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) +}) + +describe('!friends', () => { + it('reports noHero when there is no hero in GSI', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!friends', permission: 4 })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('hero') + }) + + it('reports notPlaying when a hero exists but there is no live match', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!friends', + permission: 4, + clientOverrides: { gsi: { hero: { name: 'npc_dota_hero_antimage' } } as any }, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) + + it('blocks non-broadcaster permission levels', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!friends', permission: 2, userName: 'modUser' }), + ) + expect(state.chatSayCalls).toHaveLength(0) + }) +}) diff --git a/packages/dota/src/twitch/lib/__tests__/redisCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/redisCommands.integration.test.ts new file mode 100644 index 000000000..acbd9c152 --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/redisCommands.integration.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() +}) + +describe('!clearsharing', () => { + it('deletes the active-steam-ids redis key and confirms success', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!clearsharing' })) + expect(state.redisDelCalls).toContain('token:token-abc:activeSteam32Ids') + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.length).toBeGreaterThan(0) + }) + + it('blocks viewers (permission below mod)', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!clearsharing', permission: 0, userName: 'viewer' }), + ) + expect(state.redisDelCalls).toHaveLength(0) + expect(state.chatSayCalls).toHaveLength(0) + }) +}) + +describe('!lgs', () => { + it('reports unknownSteam when there is no steam id', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!lgs', clientOverrides: { steam32Id: null } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('steam') + }) + + it('reports the multiAccount message when no steam id and multiAccount is set', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!lgs', + clientOverrides: { steam32Id: null, multiAccount: true } as any, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('dotabod.com/dashboard/features') + }) +}) diff --git a/packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts new file mode 100644 index 000000000..4b8c3dd92 --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/settingsCommands.integration.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { DBSettings } from '../../../settings.ts' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// Mod commands that persist a setting via supabase.from('settings').upsert() +// (captured in state.upsertCalls). + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() +}) + +describe('!setdelay', () => { + it('rejects a non-numeric argument', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!setdelay abc' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.upsertCalls).toHaveLength(0) + }) + + it('persists the delay in milliseconds and confirms', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!setdelay 5' })) + expect(state.upsertCalls).toHaveLength(1) + expect(state.upsertCalls[0].values).toMatchObject({ key: DBSettings.streamDelay, value: 5000 }) + expect(state.chatSayCalls[0].message).toContain('5') + }) + + it('clamps a delay above the 3000s maximum', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!setdelay 9999' })) + expect(state.upsertCalls[0].values).toMatchObject({ value: 3000 * 1000 }) + }) + + it('treats 0 as removing the delay', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!setdelay 0' })) + expect(state.upsertCalls[0].values).toMatchObject({ value: 0 }) + expect(state.chatSayCalls[0].message.toLowerCase()).toMatch(/remov|delay/) + }) +}) + +describe('!only', () => { + it('reports disabled status when no args and rank-only is off', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!only' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.upsertCalls).toHaveLength(0) + }) + + it('enables rank-only mode for a valid rank', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!only herald' })) + expect(state.upsertCalls).toHaveLength(1) + const value = JSON.parse(state.upsertCalls[0].values.value as string) + expect(value).toMatchObject({ enabled: true, minimumRank: 'Herald' }) + expect(state.chatSayCalls[0].message).toContain('dotabod.com/verify') + }) + + it('disables rank-only mode with "off"', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!only off' })) + expect(state.upsertCalls).toHaveLength(1) + const value = JSON.parse(state.upsertCalls[0].values.value as string) + expect(value.enabled).toBe(false) + }) + + it('rejects an unrecognized rank', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!only notarank' })) + expect(state.upsertCalls).toHaveLength(0) + expect(state.chatSayCalls).toHaveLength(1) + }) +}) + +describe('!mute', () => { + it('toggles the chatter setting and announces it', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!mute' })) + expect(state.upsertCalls).toHaveLength(1) + expect(state.upsertCalls[0].values).toMatchObject({ key: DBSettings.chatter }) + expect(typeof state.upsertCalls[0].values.value).toBe('boolean') + expect(state.chatSayCalls).toHaveLength(1) + }) + + it('blocks viewers (permission below mod)', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!mute', permission: 0, userName: 'viewer' }), + ) + expect(state.upsertCalls).toHaveLength(0) + expect(state.chatSayCalls).toHaveLength(0) + }) +}) diff --git a/packages/dota/src/twitch/lib/__tests__/setupMocks.ts b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts index 87b847a54..34ce11e50 100644 --- a/packages/dota/src/twitch/lib/__tests__/setupMocks.ts +++ b/packages/dota/src/twitch/lib/__tests__/setupMocks.ts @@ -44,6 +44,7 @@ export const state: { redisGet: Record redisDelCalls: string[] updateCalls: Array<{ values: Record; whereId: string | null }> + upsertCalls: Array<{ values: Record; options?: unknown }> updateMmrCalls: Array> chatSayCalls: Array<{ channel: string; message: string; messageId?: string }> steamSocketResponse: { matches: unknown[] } | null @@ -65,6 +66,8 @@ export const state: { botBanned: boolean subscriberOnlyMode: boolean chatSettingsUpdates: Array<{ channelId: string; settings: Record }> + // Result returned by the mocked MongoDB `delayedGames` findOne (ranked, spectators, ...). + delayedGame: Record | null } = { sessionMatch: null, olderMatch: null, @@ -72,6 +75,7 @@ export const state: { redisGet: {}, redisDelCalls: [], updateCalls: [], + upsertCalls: [], updateMmrCalls: [], chatSayCalls: [], steamSocketResponse: null, @@ -93,6 +97,7 @@ export const state: { botBanned: false, subscriberOnlyMode: false, chatSettingsUpdates: [], + delayedGame: null, } export function resetState() { @@ -102,6 +107,7 @@ export function resetState() { state.redisGet = {} state.redisDelCalls = [] state.updateCalls = [] + state.upsertCalls = [] state.updateMmrCalls = [] state.chatSayCalls = [] state.steamSocketResponse = null @@ -123,6 +129,7 @@ export function resetState() { state.botBanned = false state.subscriberOnlyMode = false state.chatSettingsUpdates = [] + state.delayedGame = null } // Supabase chainable mock. Three query shapes need to be distinguished: @@ -145,6 +152,10 @@ function createSupabaseFromBuilder() { updateValues = values return builder }, + upsert: (values: Record, options?: unknown) => { + state.upsertCalls.push({ values, options }) + return Promise.resolve({ data: null, error: null }) + }, eq: (col: string, val: string) => { if (mode === 'update' && col === 'id') { updateWhereId = val @@ -269,6 +280,19 @@ mock.module('../../../dota/lib/ranks', () => ({ getRankDescription: async () => state.rankDescription, })) +// Mongo is only used by a few match-data commands (ranked, spectators, ...). +// connect() yields a db whose delayedGames.findOne returns state.delayedGame. +mock.module('../../../steam/MongoDBSingleton', () => ({ + default: { + connect: async () => ({ + collection: () => ({ + findOne: async () => state.delayedGame, + }), + }), + close: async () => undefined, + }, +})) + await initTestI18n() // Import after all module mocks are registered. @@ -296,6 +320,40 @@ await import('../../commands/dotabuff') await import('../../commands/pleb') await import('../../commands/apm') await import('../../commands/avg') +await import('../../commands/version') +await import('../../commands/commands') +await import('../../commands/dotabod') +await import('../../commands/steam') +await import('../../commands/song') +await import('../../commands/match') +await import('../../commands/xpm') +await import('../../commands/aghs') +await import('../../commands/shard') +await import('../../commands/d2pt') +await import('../../commands/facet') +await import('../../commands/innate') +await import('../../commands/mmr=') +await import('../../commands/modsonly') +await import('../../commands/only') +await import('../../commands/setdelay') +await import('../../commands/mute') +await import('../../commands/ranked') +await import('../../commands/spectators') +await import('../../commands/friends') +await import('../../commands/opendota') +await import('../../commands/profile') +await import('../../commands/beta') +await import('../../commands/toggle') +await import('../../commands/today') +await import('../../commands/clearsharing') +await import('../../commands/lgs') +await import('../../commands/items') +await import('../../commands/stats') +await import('../../commands/geo') +await import('../../commands/gm') +await import('../../commands/np') +await import('../../commands/smurfs') +await import('../../commands/lg') // Monkey-patch the singletons we need behavior control over. Mocking these // modules wholesale would force us to enumerate every other transitive export. diff --git a/packages/dota/src/twitch/lib/__tests__/simpleCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/simpleCommands.integration.test.ts new file mode 100644 index 000000000..030ee0e2f --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/simpleCommands.integration.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// Covers the simple, formatting-only commands (no GSI/DB coupling) dispatched +// via commandHandler.handleMessage(). Companion to commands.integration.test.ts. + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() +}) + +describe('!dotabod', () => { + it('chats the dotabod info message', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!dotabod' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('dotabod.com') + expect(state.chatSayCalls[0].message).toContain('@techleed') + }) +}) + +describe('!commands', () => { + it('chats a link to the channel commands page', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!commands' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('dotabod.com/streamer') + }) +}) + +describe('!version', () => { + const original = process.env.COMMIT_HASH + + afterEach(() => { + if (original === undefined) delete process.env.COMMIT_HASH + else process.env.COMMIT_HASH = original + }) + + it('reports the unknown-version message when COMMIT_HASH is unset', async () => { + delete process.env.COMMIT_HASH + await commandHandler.handleMessage(makeMessage({ content: '!version' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('github.com/dotabod/backend') + }) + + it('reports the commit hash and compare URL when COMMIT_HASH is set', async () => { + process.env.COMMIT_HASH = 'abc1234' + await commandHandler.handleMessage(makeMessage({ content: '!version' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('abc1234') + }) +}) + +describe('!steam', () => { + it('chats the steamid.xyz link when a steam32Id is known', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!steam' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toBe('steamid.xyz/99999') + }) + + it('reports unknownSteam when no steam32Id and not multiAccount', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!steam', clientOverrides: { steam32Id: null } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('steam') + }) + + it('reports the multiAccount message when no steam32Id and multiAccount is set', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!steam', + clientOverrides: { steam32Id: null, multiAccount: true } as any, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('dotabod.com/dashboard/features') + }) +}) + +describe('!match', () => { + it('reports gameNotFound when there is no live match', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!match' })) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('game') + }) + + it('chats the match id when GSI has one', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!match', + clientOverrides: { gsi: { map: { matchid: '7777777777' } } } as any, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('7777777777') + }) + + it('blocks when the stream is offline (onlyOnline gate)', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!match', clientOverrides: { stream_online: false } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) +}) + +describe('!song', () => { + it('reports lastFmNotConfigured when the command is enabled but no username is set', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!song', + clientOverrides: { settings: [{ key: 'commandLastFm', value: true }] } as any, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toMatch(/last|fm|configure/) + }) + + it('blocks when the stream is offline (onlyOnline gate)', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!song', clientOverrides: { stream_online: false } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('PauseChamp') + }) +}) diff --git a/packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts b/packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts new file mode 100644 index 000000000..71bf35ec3 --- /dev/null +++ b/packages/dota/src/twitch/lib/__tests__/toggleCommands.integration.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { flushAsync } from '../../../__tests__/sharedMocks.ts' +import { DBSettings } from '../../../settings.ts' +import { commandHandler, makeMessage, resetState, state } from './setupMocks.ts' + +// Mod commands that flip a persisted flag (beta -> users.update, toggle -> +// settings.upsert) plus the no-steam branches of !today. + +beforeEach(() => { + resetState() + commandHandler.cooldowns.clear() +}) + +describe('!beta', () => { + it('flips beta_tester and announces it', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!beta' })) + await flushAsync() + expect(state.updateCalls).toHaveLength(1) + expect(state.updateCalls[0].values).toMatchObject({ beta_tester: true }) + expect(state.chatSayCalls).toHaveLength(1) + }) + + it('blocks viewers (permission below mod)', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!beta', permission: 0, userName: 'viewer' }), + ) + await flushAsync() + expect(state.updateCalls).toHaveLength(0) + }) +}) + +describe('!toggle', () => { + it('persists the inverted commandDisable flag (no chat output)', async () => { + await commandHandler.handleMessage(makeMessage({ content: '!toggle' })) + expect(state.upsertCalls).toHaveLength(1) + expect(state.upsertCalls[0].values).toMatchObject({ + key: DBSettings.commandDisable, + value: true, + }) + expect(state.chatSayCalls).toHaveLength(0) + }) + + it('blocks viewers (permission below mod)', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!toggle', permission: 0, userName: 'viewer' }), + ) + expect(state.upsertCalls).toHaveLength(0) + }) +}) + +describe('!today', () => { + it('reports unknownSteam when there is no steam id', async () => { + await commandHandler.handleMessage( + makeMessage({ content: '!today', clientOverrides: { steam32Id: null } }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message.toLowerCase()).toContain('steam') + }) + + it('reports the multiAccount message when no steam id and multiAccount is set', async () => { + await commandHandler.handleMessage( + makeMessage({ + content: '!today', + clientOverrides: { steam32Id: null, multiAccount: true } as any, + }), + ) + expect(state.chatSayCalls).toHaveLength(1) + expect(state.chatSayCalls[0].message).toContain('dotabod.com/dashboard/features') + }) +}) diff --git a/packages/dota/src/utils/__tests__/subscription.test.ts b/packages/dota/src/utils/__tests__/subscription.test.ts new file mode 100644 index 000000000..792a2d3c0 --- /dev/null +++ b/packages/dota/src/utils/__tests__/subscription.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'bun:test' +import type { SubscriptionRow } from '../../types/subscription.ts' +import { + canAccessFeature, + getRequiredTier, + isChatterKey, + isInGracePeriod, +} from '../subscription.ts' + +const sub = (overrides: Partial): SubscriptionRow => + ({ id: 's1', tier: 'PRO', status: 'ACTIVE', isGift: false, ...overrides }) as SubscriptionRow + +describe('getRequiredTier', () => { + it('defaults to PRO when no feature is given', () => { + expect(getRequiredTier()).toBe('PRO') + }) + + it('returns FREE for a free feature', () => { + expect(getRequiredTier('mmr')).toBe('FREE') + }) + + it('returns PRO for a pro feature', () => { + expect(getRequiredTier('bets')).toBe('PRO') + }) + + it('resolves generic features', () => { + expect(getRequiredTier('managers')).toBe('PRO') + }) + + it('falls back to PRO for an unknown feature', () => { + expect(getRequiredTier('not-a-real-feature' as any)).toBe('PRO') + }) +}) + +describe('isChatterKey', () => { + it('is true for chatters.* keys', () => { + expect(isChatterKey('chatters.midas')).toBe(true) + }) + + it('is false for non-chatter keys', () => { + expect(isChatterKey('mmr')).toBe(false) + }) +}) + +describe('isInGracePeriod', () => { + it('is over (grace period ended 2025-04-30)', () => { + expect(isInGracePeriod()).toBe(false) + }) +}) + +describe('canAccessFeature', () => { + it('grants free features regardless of subscription', () => { + expect(canAccessFeature('mmr', null)).toEqual({ hasAccess: true, requiredTier: 'FREE' }) + }) + + it('denies pro features without a subscription', () => { + expect(canAccessFeature('bets', null)).toEqual({ hasAccess: false, requiredTier: 'PRO' }) + }) + + it('grants a pro feature to an active PRO subscriber', () => { + expect(canAccessFeature('bets', sub({ tier: 'PRO', status: 'ACTIVE' }))).toEqual({ + hasAccess: true, + requiredTier: 'PRO', + }) + }) + + it('grants a pro feature to a TRIALING PRO subscriber', () => { + expect(canAccessFeature('bets', sub({ tier: 'PRO', status: 'TRIALING' })).hasAccess).toBe(true) + }) + + it('denies a pro feature to a FREE-tier subscriber', () => { + expect(canAccessFeature('bets', sub({ tier: 'FREE', status: 'ACTIVE' })).hasAccess).toBe(false) + }) + + it('denies a pro feature when the subscription is a gift (not yet active)', () => { + expect(canAccessFeature('bets', sub({ tier: 'PRO', isGift: true })).hasAccess).toBe(false) + }) + + it('denies a pro feature when the subscription is canceled', () => { + expect( + canAccessFeature('bets', sub({ tier: 'PRO', status: 'CANCELED' as any })).hasAccess, + ).toBe(false) + }) +}) diff --git a/packages/profanity-filter/Dockerfile b/packages/profanity-filter/Dockerfile deleted file mode 100644 index d6a30a230..000000000 --- a/packages/profanity-filter/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -FROM oven/bun:1.0.30-slim as build - -WORKDIR /app - -# Copy package.json and bun.lock -COPY package.json ./ - -# Install dependencies -RUN bun install - -# Copy source code -COPY . . - -# Build the application if needed -RUN bun build ./src/index.ts --target=bun --outdir=./dist - -# Production stage -FROM oven/bun:1.0.30-slim - -WORKDIR /app - -# Copy built application from build stage -COPY --from=build /app/dist /app/dist -COPY --from=build /app/node_modules /app/node_modules -COPY --from=build /app/package.json /app/package.json - -# Set environment variables -ENV PORT=3000 -ENV NODE_ENV=production - -# Expose the port the app will run on -EXPOSE 3000 - -# Command to run the application -CMD ["bun", "dist/index.js"] diff --git a/packages/shared-utils/tests/conduitManager.test.ts b/packages/shared-utils/tests/conduitManager.test.ts new file mode 100644 index 000000000..1372a6b4a --- /dev/null +++ b/packages/shared-utils/tests/conduitManager.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { resetUtilsState } from './setupMocks.ts' + +// The module reads TWITCH_CONDUIT_ID once at import time; clear it first so the +// env-override short-circuit doesn't bypass the fetch logic under test. +delete process.env.TWITCH_CONDUIT_ID + +const { fetchConduitId, updateConduitShard } = await import('../src/twitch/conduitManager') + +type FakeResponse = { + status?: number + json?: unknown + text?: string +} + +const realFetch = globalThis.fetch +const realSetTimeout = globalThis.setTimeout + +let fetchQueue: FakeResponse[] = [] +let fetchCallCount = 0 + +function res({ status = 200, json, text }: FakeResponse) { + return { + ok: status >= 200 && status < 300, + status, + statusText: 'Status', + json: async () => json, + text: async () => text ?? '', + } +} + +beforeEach(() => { + resetUtilsState() + fetchQueue = [] + fetchCallCount = 0 + globalThis.fetch = (async () => { + fetchCallCount++ + const next = fetchQueue.shift() + if (!next) throw new Error('Unexpected fetch call (queue empty)') + return res(next) + }) as unknown as typeof fetch +}) + +afterEach(() => { + globalThis.fetch = realFetch + globalThis.setTimeout = realSetTimeout +}) + +describe('fetchConduitId', () => { + it('returns the first existing conduit id', async () => { + fetchQueue = [{ json: { data: [{ id: 'existing-1', shard_count: 1 }] } }] + await expect(fetchConduitId(true)).resolves.toBe('existing-1') + expect(fetchCallCount).toBe(1) + }) + + it('creates a new conduit when none exist', async () => { + fetchQueue = [ + { json: { data: [] } }, // GET existing -> empty + { json: { data: [{ id: 'created-1' }] } }, // POST create + ] + await expect(fetchConduitId(true)).resolves.toBe('created-1') + expect(fetchCallCount).toBe(2) + }) + + it('retries with fresh headers after a 401 and uses the retried result', async () => { + fetchQueue = [{ status: 401 }, { json: { data: [{ id: 'retry-1' }] } }] + await expect(fetchConduitId(true)).resolves.toBe('retry-1') + expect(fetchCallCount).toBe(2) + }) + + it('returns null when the conduits request fails', async () => { + fetchQueue = [{ status: 500, text: 'server error' }] + await expect(fetchConduitId(true)).resolves.toBeNull() + }) + + it('serves the cached id without re-fetching when not forcing a refresh', async () => { + fetchQueue = [{ json: { data: [{ id: 'cache-1' }] } }] + await expect(fetchConduitId(true)).resolves.toBe('cache-1') + const callsAfterFirst = fetchCallCount + + await expect(fetchConduitId(false)).resolves.toBe('cache-1') + expect(fetchCallCount).toBe(callsAfterFirst) + }) +}) + +describe('updateConduitShard', () => { + it('returns true on a 202 with no errors', async () => { + fetchQueue = [{ status: 202, json: {} }] + await expect(updateConduitShard('sess-1', 'conduit-1')).resolves.toBe(true) + }) + + it('returns false when the 202 response contains errors', async () => { + fetchQueue = [{ status: 202, json: { errors: [{ message: 'bad shard' }] } }] + await expect(updateConduitShard('sess-1', 'conduit-1')).resolves.toBe(false) + }) + + it('retries after a 401 and succeeds on the next attempt', async () => { + // Fire backoff timers immediately so the retry path runs without real delay. + globalThis.setTimeout = ((cb: () => void) => { + cb() + return 0 as unknown as ReturnType + }) as typeof setTimeout + + fetchQueue = [{ status: 401 }, { status: 202, json: {} }] + await expect(updateConduitShard('sess-1', 'conduit-1')).resolves.toBe(true) + expect(fetchCallCount).toBe(2) + }) + + it('gives up after exhausting retries on persistent failures', async () => { + globalThis.setTimeout = ((cb: () => void) => { + cb() + return 0 as unknown as ReturnType + }) as typeof setTimeout + + fetchQueue = Array.from({ length: 6 }, () => ({ status: 500, text: 'nope' })) + await expect(updateConduitShard('sess-1', 'conduit-1')).resolves.toBe(false) + // initial attempt + 5 retries + expect(fetchCallCount).toBe(6) + }) +}) diff --git a/packages/twitch-chat/bunfig.toml b/packages/twitch-chat/bunfig.toml new file mode 100644 index 000000000..cd4562936 --- /dev/null +++ b/packages/twitch-chat/bunfig.toml @@ -0,0 +1,2 @@ +[test] +root = "src" diff --git a/packages/twitch-chat/package.json b/packages/twitch-chat/package.json index 904d1bf4c..d315f3771 100644 --- a/packages/twitch-chat/package.json +++ b/packages/twitch-chat/package.json @@ -14,6 +14,7 @@ "docker:production": "bun ./dist/index.js", "docker:development": "bun src/index.ts", "build": "bun build --target=bun --outfile=./dist/index.js --outdir=./dist --production --sourcemap=external --packages=external ./src/index.ts", + "test": "bun test", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/twitch-chat/src/__tests__/handleChat.test.ts b/packages/twitch-chat/src/__tests__/handleChat.test.ts new file mode 100644 index 000000000..9c97b2d9b --- /dev/null +++ b/packages/twitch-chat/src/__tests__/handleChat.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { handleChatMessage, resetState, sendTwitchChatMessage, state } from './sharedMocks.ts' + +type EventOverrides = { + text?: string + badges?: { set_id: string }[] + chatterId?: string + channelId?: string + reply?: { parent_message_id: string } | undefined + sourceMessageId?: string | null +} + +function makeMessage(overrides: EventOverrides = {}) { + return { + payload: { + subscription: { type: 'channel.chat.message' }, + event: { + chatter_user_login: 'viewer', + chatter_user_id: overrides.chatterId ?? 'user-1', + message: { text: overrides.text ?? 'hello' }, + message_id: 'msg-1', + broadcaster_user_id: overrides.channelId ?? 'chan-1', + broadcaster_user_login: 'streamer', + badges: overrides.badges ?? [], + reply: overrides.reply, + source_message_id: overrides.sourceMessageId ?? null, + }, + }, + } +} + +describe('handleChatMessage', () => { + beforeEach(() => resetState()) + + it('ignores payloads without a subscription/event', async () => { + await handleChatMessage({ payload: {} }) + expect(state.emitCalls).toHaveLength(0) + }) + + it('ignores shared-chat messages (non-null source_message_id)', async () => { + await handleChatMessage(makeMessage({ sourceMessageId: 'other-chan' })) + expect(state.emitCalls).toHaveLength(0) + }) + + it('emits the chat message over the socket when connected', async () => { + await handleChatMessage(makeMessage({ text: 'hi there' })) + expect(state.emitCalls).toHaveLength(1) + expect(state.emitCalls[0]).toMatchObject({ + broadcasterLogin: 'streamer', + chatterLogin: 'viewer', + text: 'hi there', + }) + }) + + it('strips the leading @mention from a reply that contains a command', async () => { + await handleChatMessage({ + ...makeMessage({ text: '@streamer !mmr' }), + } as any) + expect(state.emitCalls[0].text).toBe('!mmr') + }) + + it('uses the reply parent message id as the message id when present', async () => { + await handleChatMessage(makeMessage({ reply: { parent_message_id: 'parent-99' } })) + expect(state.emitCalls[0].opts.messageId).toBe('parent-99') + }) + + it('derives mod/broadcaster/subscriber flags from badges', async () => { + await handleChatMessage( + makeMessage({ + chatterId: 'chan-1', + channelId: 'chan-1', + badges: [{ set_id: 'moderator' }, { set_id: 'subscriber' }], + }), + ) + expect(state.emitCalls[0].opts.userInfo).toEqual({ + isMod: true, + isBroadcaster: true, + isSubscriber: true, + userId: 'chan-1', + }) + }) + + it('does not emit and does not message when the bot is banned (offline)', async () => { + state.hasSocket = false + state.isBanned = true + await handleChatMessage(makeMessage({ text: '!ping' })) + expect(state.emitCalls).toHaveLength(0) + expect(state.fetchCalls).toHaveLength(0) + }) + + it('replies to !ping over the API when offline and not banned', async () => { + state.hasSocket = false + state.isBanned = false + await handleChatMessage(makeMessage({ text: '!ping' })) + expect(state.fetchCalls).toHaveLength(1) + expect(state.fetchCalls[0].url).toBe('https://api.twitch.tv/helix/chat/messages') + }) +}) + +describe('sendTwitchChatMessage', () => { + beforeEach(() => resetState()) + + it('drops the message when the broadcaster is being disabled', async () => { + state.isDisabled = true + const res = await sendTwitchChatMessage({ + broadcaster_id: 'b1', + sender_id: 's1', + message: 'disabled-case', + }) + expect(res.data[0].is_sent).toBe(false) + expect(res.data[0].drop_reason?.code).toBe('user_being_disabled') + expect(state.fetchCalls).toHaveLength(0) + }) + + it('drops a duplicate message sent within the dedupe window', async () => { + const params = { broadcaster_id: 'b-dupe', sender_id: 's1', message: 'dupe-case' } + await sendTwitchChatMessage(params) + const res = await sendTwitchChatMessage(params) + expect(res.data[0].drop_reason?.code).toBe('duplicate_message') + expect(state.fetchCalls).toHaveLength(1) + }) + + it('returns the API response on success', async () => { + state.fetchImpl = async () => ({ + ok: true, + json: async () => ({ data: [{ message_id: 'real-id', is_sent: true }] }), + }) + const res = await sendTwitchChatMessage({ + broadcaster_id: 'b-ok', + sender_id: 's1', + message: 'success-case', + }) + expect(res.data[0]).toEqual({ message_id: 'real-id', is_sent: true }) + }) + + it('flags rate limiting on a 429 response', async () => { + state.fetchImpl = async () => ({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: async () => '', + }) + const res = await sendTwitchChatMessage({ + broadcaster_id: 'b-429', + sender_id: 's1', + message: 'rate-case', + }) + expect(res.data[0].drop_reason?.code).toBe('rate_limited') + }) + + it('includes the error body for a non-429 failure', async () => { + state.fetchImpl = async () => ({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'invalid sender', + }) + const res = await sendTwitchChatMessage({ + broadcaster_id: 'b-400', + sender_id: 's1', + message: 'badreq-case', + }) + expect(res.data[0].drop_reason?.code).toBe('send_error') + expect(res.data[0].drop_reason?.message).toContain('invalid sender') + }) + + it('handles a thrown fetch error and logs it', async () => { + state.fetchThrows = new Error('network down') + const res = await sendTwitchChatMessage({ + broadcaster_id: 'b-throw', + sender_id: 's1', + message: 'throw-case', + }) + expect(res.data[0].drop_reason?.code).toBe('send_error') + expect(res.data[0].drop_reason?.message).toBe('network down') + expect(state.logError).toHaveLength(1) + }) +}) diff --git a/packages/twitch-chat/src/__tests__/sharedMocks.ts b/packages/twitch-chat/src/__tests__/sharedMocks.ts new file mode 100644 index 000000000..c3e3fb310 --- /dev/null +++ b/packages/twitch-chat/src/__tests__/sharedMocks.ts @@ -0,0 +1,108 @@ +// Shared test harness for twitch-chat. Filename is intentionally NOT `.test.ts` +// so bun's runner ignores it. +// +// Why this exists: bun's `mock.module()` is process-wide. Any test file that +// needs `@dotabod/shared-utils`, `i18next`, or the sibling modules below mocked +// must route through this single harness so competing factories for the same +// module spec don't collide when the whole suite runs together. Import the SUT +// from here, not from its real path. (The pure transform tests don't touch +// these modules, so they import their SUTs directly.) +import { mock } from 'bun:test' + +type FetchResponse = { + ok: boolean + status?: number + statusText?: string + text?: () => Promise + json?: () => Promise +} + +export const state: { + isDisabled: boolean + isBanned: boolean + hasSocket: boolean + emitCalls: Array<{ + broadcasterLogin: string + chatterLogin: string + text: string + opts: Record + }> + fetchCalls: Array<{ url: string; options: any }> + fetchImpl: (url: string, options: any) => Promise + fetchThrows: unknown + logError: Array<{ message: string; meta: Record }> +} = { + isDisabled: false, + isBanned: false, + hasSocket: true, + emitCalls: [], + fetchCalls: [], + fetchImpl: async () => ({ + ok: true, + json: async () => ({ data: [{ message_id: 'mid', is_sent: true }] }), + }), + fetchThrows: null, + logError: [], +} + +export function resetState() { + state.isDisabled = false + state.isBanned = false + state.hasSocket = true + state.emitCalls = [] + state.fetchCalls = [] + state.fetchImpl = async () => ({ + ok: true, + json: async () => ({ data: [{ message_id: 'mid', is_sent: true }] }), + }) + state.fetchThrows = null + state.logError = [] + clearDedupeCache() +} + +mock.module('@dotabod/shared-utils', () => ({ + logger: { + info: () => undefined, + warn: () => undefined, + debug: () => undefined, + error: (message: string, meta?: Record) => + state.logError.push({ message, meta: meta ?? {} }), + }, + checkBotStatus: async () => state.isBanned, + getTwitchHeaders: async () => ({ Authorization: 'Bearer test' }), +})) + +mock.module('i18next', () => ({ + t: (key: string) => `t:${key}`, +})) + +mock.module('../disableCache', () => ({ + isBroadcasterBeingDisabled: () => state.isDisabled, +})) + +mock.module('../utils/socketManager', () => ({ + hasDotabodSocket: () => state.hasSocket, + emitChatMessage: ( + broadcasterLogin: string, + chatterLogin: string, + text: string, + opts: Record, + ) => { + state.emitCalls.push({ broadcasterLogin, chatterLogin, text, opts }) + }, +})) + +// Route fetch through state so each test controls the HTTP response. +globalThis.fetch = (async (url: string, options: any) => { + state.fetchCalls.push({ url, options }) + if (state.fetchThrows) throw state.fetchThrows + return state.fetchImpl(url, options) +}) as unknown as typeof fetch + +// Import after mocks are registered. +export const { + sendTwitchChatMessage, + handleChatMessage, + ChatMessageResponseCode, + clearDedupeCache, +} = await import('../handleChat') diff --git a/packages/twitch-chat/src/event-handlers/__tests__/transformBetData.test.ts b/packages/twitch-chat/src/event-handlers/__tests__/transformBetData.test.ts new file mode 100644 index 000000000..5a66feba9 --- /dev/null +++ b/packages/twitch-chat/src/event-handlers/__tests__/transformBetData.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test' +import { transformBetData } from '../transformBetData.ts' + +describe('transformBetData', () => { + it('maps title and converts locked_at to an endDate', () => { + const result = transformBetData({ + title: 'Will we win?', + locked_at: '2026-05-20T00:00:00.000Z', + outcomes: [], + }) + + expect(result.title).toBe('Will we win?') + expect(result.endDate).toEqual(new Date('2026-05-20T00:00:00.000Z')) + expect(result.outcomes).toEqual([]) + }) + + it('uses empty string endDate when locked_at is absent', () => { + const result = transformBetData({ title: 'No lock', outcomes: [] }) + expect(result.endDate).toBe('') + }) + + it('maps outcomes with top_predictors into totals and topUsers', () => { + const result = transformBetData({ + title: 'Match', + outcomes: [ + { + title: 'Yes', + channel_points: 500, + users: 3, + top_predictors: [ + { user_name: 'alice', channel_points_used: 100, channel_points_won: 200 }, + ], + }, + ], + }) + + expect(result.outcomes).toEqual([ + { + totalVotes: 500, + totalUsers: 3, + title: 'Yes', + topUsers: [{ userDisplayName: 'alice', channelPointsUsed: 100, channelPointsWon: 200 }], + }, + ]) + }) + + it('leaves totals and topUsers undefined when top_predictors is absent', () => { + const result = transformBetData({ + title: 'Match', + outcomes: [{ title: 'No', channel_points: 500, users: 3 }], + }) + + expect(result.outcomes).toEqual([ + { totalVotes: undefined, totalUsers: undefined, title: 'No', topUsers: undefined }, + ]) + }) + + it('returns undefined outcomes when none provided', () => { + expect(transformBetData({ title: 'x' }).outcomes).toBeUndefined() + }) +}) diff --git a/packages/twitch-chat/src/event-handlers/__tests__/transformPollData.test.ts b/packages/twitch-chat/src/event-handlers/__tests__/transformPollData.test.ts new file mode 100644 index 000000000..4a813b01d --- /dev/null +++ b/packages/twitch-chat/src/event-handlers/__tests__/transformPollData.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test' +import { transformPollData } from '../transformPollData.ts' + +describe('transformPollData', () => { + it('maps title and choices, converting end_date to a Date', () => { + const result = transformPollData({ + title: 'Favorite hero?', + end_date: '2026-05-20T00:00:00.000Z', + choices: [ + { title: 'Pudge', total_votes: 10 }, + { title: 'Invoker', total_votes: 5 }, + ], + }) + + expect(result).toEqual({ + title: 'Favorite hero?', + endDate: new Date('2026-05-20T00:00:00.000Z'), + choices: [ + { title: 'Pudge', totalVotes: 10 }, + { title: 'Invoker', totalVotes: 5 }, + ], + }) + }) + + it('defaults totalVotes to 0 when total_votes is absent', () => { + const result = transformPollData({ title: 'p', choices: [{ title: 'A' }] }) + expect(result.choices).toEqual([{ title: 'A', totalVotes: 0 }]) + }) + + it('uses empty string endDate when end_date is absent', () => { + const result = transformPollData({ title: 'p', choices: [] }) + expect(result.endDate).toBe('') + }) +}) diff --git a/packages/twitch-chat/src/eventSubSocket.ts b/packages/twitch-chat/src/eventSubSocket.ts index b57b7ed10..d4ba6873e 100644 --- a/packages/twitch-chat/src/eventSubSocket.ts +++ b/packages/twitch-chat/src/eventSubSocket.ts @@ -77,6 +77,13 @@ export class EventsubSocket extends EventEmitter { } private handleClose(close: WebSocket.CloseEvent, isReconnect: boolean): void { + // Ignore closes from a stale socket (e.g. the old connection shutting down + // after a session_reconnect handed off to a new one). Otherwise it clobbers + // the live connection's status and can spawn duplicate reconnects. + if (close.target !== this.eventsub) { + return + } + eventsubConnected = false this.emit('close', close) console.debug( @@ -181,6 +188,9 @@ export class EventsubSocket extends EventEmitter { if (keepalive_timeout_seconds) { this.silenceTime = keepalive_timeout_seconds + 1 } + // Any keepalive/notification proves the connection is live — refresh the + // flag so a stale close can't leave it stuck false while events still flow. + eventsubConnected = true clearTimeout(this.silenceHandler) this.silenceHandler = setTimeout(() => { eventsubConnected = false diff --git a/packages/twitch-chat/src/handleChat.ts b/packages/twitch-chat/src/handleChat.ts index 947842709..6f81f32db 100644 --- a/packages/twitch-chat/src/handleChat.ts +++ b/packages/twitch-chat/src/handleChat.ts @@ -7,6 +7,11 @@ import { emitChatMessage, hasDotabodSocket } from './utils/socketManager' const messageDedupeCache = new Map() const DEDUPE_WINDOW_MS = 5000 // 5 seconds +// Test seam: clears the module-level dedupe cache so suites don't leak state. +export function clearDedupeCache() { + messageDedupeCache.clear() +} + // Clean up expired cache entries periodically setInterval(() => { const now = Date.now() diff --git a/packages/twitch-events/bunfig.toml b/packages/twitch-events/bunfig.toml new file mode 100644 index 000000000..cd4562936 --- /dev/null +++ b/packages/twitch-events/bunfig.toml @@ -0,0 +1,2 @@ +[test] +root = "src" diff --git a/packages/twitch-events/package.json b/packages/twitch-events/package.json index cedba7bfb..13825dde3 100644 --- a/packages/twitch-events/package.json +++ b/packages/twitch-events/package.json @@ -15,6 +15,7 @@ "docker:development": "bun --watch src/index.ts", "build": "bun build --target=bun --outfile=./dist/index.js --outdir=./dist --production --sourcemap=external --packages=external ./src/index.ts", "subscription-health-check": "bun src/utils/subscriptionHealthCheck.ts", + "test": "bun test", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/twitch-events/src/__tests__/sharedMocks.ts b/packages/twitch-events/src/__tests__/sharedMocks.ts new file mode 100644 index 000000000..32c01cd6b --- /dev/null +++ b/packages/twitch-events/src/__tests__/sharedMocks.ts @@ -0,0 +1,92 @@ +// Shared test harness for twitch-events. Filename is intentionally NOT +// `.test.ts` so bun's runner ignores it. +// +// Why this exists: bun's `mock.module()` is process-wide. Every test file that +// needs `@dotabod/shared-utils` (or the sibling modules below) mocked must go +// through this single harness, otherwise competing factories for the same +// module spec collide when the whole suite runs together (passes in isolation, +// fails together). Import the SUTs from here, not from their real paths. +import { mock } from 'bun:test' +import type { TwitchEventTypes } from '../TwitchEventTypes.ts' + +type LogCall = { message: string; meta: Record } +type SubscribeCall = { conduitId: string; userId: string; type: keyof TwitchEventTypes } + +export const state: { + conduitId: string + isBanned: boolean + accountIds: string[] + // Per-(userId,type) subscribe result; default true. Throw by setting an Error. + subscribeResult: (userId: string, type: keyof TwitchEventTypes) => boolean | Promise + subscribeCalls: SubscribeCall[] + logInfo: LogCall[] + logWarn: LogCall[] + logError: LogCall[] +} = { + conduitId: 'conduit-1', + isBanned: false, + accountIds: [], + subscribeResult: () => true, + subscribeCalls: [], + logInfo: [], + logWarn: [], + logError: [], +} + +export function resetState() { + state.conduitId = 'conduit-1' + state.isBanned = false + state.accountIds = [] + state.subscribeResult = () => true + state.subscribeCalls = [] + state.logInfo = [] + state.logWarn = [] + state.logError = [] +} + +const logger = { + info: (message: string, meta?: Record) => + state.logInfo.push({ message, meta: meta ?? {} }), + warn: (message: string, meta?: Record) => + state.logWarn.push({ message, meta: meta ?? {} }), + error: (message: string, meta?: Record) => + state.logError.push({ message, meta: meta ?? {} }), + debug: () => undefined, +} + +mock.module('@dotabod/shared-utils', () => ({ + logger, + supabase: {}, + default: {}, + checkBotStatus: async () => state.isBanned, + fetchConduitId: async () => state.conduitId, + getTwitchHeaders: async () => ({}), +})) + +mock.module('../twitch/lib/getAccountIds', () => ({ + getAccountIds: async () => state.accountIds, + getAllAccountIds: async () => state.accountIds, +})) + +mock.module('../subscribeChatMessagesForUser', () => ({ + genericSubscribe: async (conduitId: string, userId: string, type: keyof TwitchEventTypes) => { + state.subscribeCalls.push({ conduitId, userId, type }) + return state.subscribeResult(userId, type) + }, + subscribeToAuthGrantOrRevoke: async () => undefined, +})) + +// Import after mocks are registered. +export const { eventSubMap } = await import('../chatSubIds') +export const { runSubscriptionHealthCheck } = await import('../utils/subscriptionHealthCheck') +export const { RateLimiter } = await import('../utils/rateLimiterCore') + +export function seedSubscriptions(userId: string, types: (keyof TwitchEventTypes)[]) { + eventSubMap[userId] = Object.fromEntries( + types.map((type) => [type, { id: `${userId}-${type}`, status: 'enabled' }]), + ) as (typeof eventSubMap)[string] +} + +export function clearSubscriptions() { + for (const key of Object.keys(eventSubMap)) delete eventSubMap[key] +} diff --git a/packages/twitch-events/src/utils/__tests__/authUtils.test.ts b/packages/twitch-events/src/utils/__tests__/authUtils.test.ts new file mode 100644 index 000000000..cff40eb12 --- /dev/null +++ b/packages/twitch-events/src/utils/__tests__/authUtils.test.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import type { Request } from 'express' +import { isAuthenticated } from '../authUtils.ts' + +const makeReq = (authorization?: string) => + ({ headers: authorization === undefined ? {} : { authorization } }) as Request + +describe('isAuthenticated', () => { + const original = process.env.TWITCH_EVENTSUB_SECRET + + beforeEach(() => { + process.env.TWITCH_EVENTSUB_SECRET = 'expected-secret' + }) + + afterEach(() => { + if (original === undefined) delete process.env.TWITCH_EVENTSUB_SECRET + else process.env.TWITCH_EVENTSUB_SECRET = original + }) + + it('returns true when the authorization header matches the secret', () => { + expect(isAuthenticated(makeReq('expected-secret'))).toBe(true) + }) + + it('returns false when the authorization header does not match', () => { + expect(isAuthenticated(makeReq('wrong-secret'))).toBe(false) + }) + + it('returns false when the authorization header is missing', () => { + expect(isAuthenticated(makeReq())).toBe(false) + }) +}) diff --git a/packages/twitch-events/src/utils/__tests__/rateLimiterCore.test.ts b/packages/twitch-events/src/utils/__tests__/rateLimiterCore.test.ts new file mode 100644 index 000000000..c5244b5ed --- /dev/null +++ b/packages/twitch-events/src/utils/__tests__/rateLimiterCore.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { RateLimiter, resetState } from '../../__tests__/sharedMocks.ts' + +const makeHeaders = (h: Record) => new Headers(h) +const realSetTimeout = globalThis.setTimeout + +describe('RateLimiter', () => { + beforeEach(() => { + resetState() + }) + + afterEach(() => { + globalThis.setTimeout = realSetTimeout + }) + + describe('updateLimits', () => { + it('parses limit/remaining and converts reset seconds to milliseconds', () => { + const rl = new RateLimiter() + const resetSeconds = 1_700_000_000 + rl.updateLimits( + makeHeaders({ + 'Ratelimit-Limit': '500', + 'Ratelimit-Remaining': '123', + 'Ratelimit-Reset': String(resetSeconds), + }), + ) + + expect(rl.rateLimitStatus.limit).toBe(500) + expect(rl.rateLimitStatus.remaining).toBe(123) + expect(rl.rateLimitStatus.reset).toBe(resetSeconds * 1000) + }) + + it('leaves existing values untouched when headers are absent', () => { + const rl = new RateLimiter() + rl.updateLimits(makeHeaders({})) + expect(rl.rateLimitStatus.limit).toBe(800) + expect(rl.rateLimitStatus.remaining).toBe(800) + }) + }) + + describe('schedule', () => { + it('resolves with the task result', async () => { + const rl = new RateLimiter() + await expect(rl.schedule(async () => 42)).resolves.toBe(42) + }) + + it('rejects when the task rejects', async () => { + const rl = new RateLimiter() + await expect( + rl.schedule(async () => { + throw new Error('boom') + }), + ).rejects.toThrow('boom') + }) + + it('runs queued tasks in FIFO order', async () => { + const rl = new RateLimiter() + const order: number[] = [] + await Promise.all([ + rl.schedule(async () => order.push(1)), + rl.schedule(async () => order.push(2)), + rl.schedule(async () => order.push(3)), + ]) + expect(order).toEqual([1, 2, 3]) + }) + + it('decrements remaining as tasks run', async () => { + const rl = new RateLimiter() + rl.updateLimits(makeHeaders({ 'Ratelimit-Remaining': '10' })) + await rl.schedule(async () => undefined) + await rl.schedule(async () => undefined) + expect(rl.rateLimitStatus.remaining).toBe(8) + }) + + it('waits for reset then refills remaining when the budget is exhausted', async () => { + // Fire the backoff timer immediately so the wait branch runs without real + // elapsed time (and without depending on wall-clock arithmetic). + globalThis.setTimeout = ((cb: () => void) => { + cb() + return 0 as unknown as ReturnType + }) as typeof setTimeout + + const rl = new RateLimiter() + // remaining 0, reset well in the future -> always takes the wait branch. + rl.updateLimits( + makeHeaders({ + 'Ratelimit-Limit': '50', + 'Ratelimit-Remaining': '0', + 'Ratelimit-Reset': String(Math.ceil((Date.now() + 60_000) / 1000)), + }), + ) + + const result = await rl.schedule(async () => 'done') + expect(result).toBe('done') + // After refill (50) and one task running, remaining is 49. + expect(rl.rateLimitStatus.remaining).toBe(49) + }) + + it('refills immediately when the reset window has already passed', async () => { + const rl = new RateLimiter() + rl.updateLimits( + makeHeaders({ + 'Ratelimit-Limit': '30', + 'Ratelimit-Remaining': '0', + 'Ratelimit-Reset': String(Math.floor((Date.now() - 5000) / 1000)), + }), + ) + + await rl.schedule(async () => undefined) + expect(rl.rateLimitStatus.remaining).toBe(29) + }) + }) +}) diff --git a/packages/twitch-events/src/utils/__tests__/subscriptionHealthCheck.test.ts b/packages/twitch-events/src/utils/__tests__/subscriptionHealthCheck.test.ts new file mode 100644 index 000000000..2c720a495 --- /dev/null +++ b/packages/twitch-events/src/utils/__tests__/subscriptionHealthCheck.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { + clearSubscriptions, + resetState, + runSubscriptionHealthCheck, + seedSubscriptions, + state, +} from '../../__tests__/sharedMocks.ts' + +const CRITICAL = ['stream.online', 'stream.offline', 'user.update', 'channel.chat.message'] as const +const SECONDARY = [ + 'channel.prediction.begin', + 'channel.prediction.progress', + 'channel.prediction.lock', + 'channel.prediction.end', + 'channel.poll.begin', + 'channel.poll.progress', + 'channel.poll.end', +] as const +const ALL = [...CRITICAL, ...SECONDARY] + +describe('runSubscriptionHealthCheck', () => { + beforeEach(() => { + resetState() + clearSubscriptions() + }) + + it('throws when no conduit ID is available', async () => { + state.conduitId = '' + state.accountIds = ['111'] + await expect(runSubscriptionHealthCheck()).rejects.toThrow('No valid conduit ID') + }) + + it('throws when there are no user accounts', async () => { + state.accountIds = [] + await expect(runSubscriptionHealthCheck()).rejects.toThrow('No user accounts found') + }) + + it('reports no issues when every subscription already exists', async () => { + state.accountIds = ['111'] + seedSubscriptions('111', ALL as any) + + const result = await runSubscriptionHealthCheck() + + expect(result.usersWithIssues).toBe(0) + expect(result.fixedSubscriptions).toBe(0) + expect(state.subscribeCalls).toHaveLength(0) + }) + + it('fixes all missing critical and secondary subscriptions', async () => { + state.accountIds = ['111'] + seedSubscriptions('111', []) // entry exists but has no types -> all missing + + const result = await runSubscriptionHealthCheck() + + expect(result.usersWithIssues).toBe(1) + expect(result.criticalFixCount).toBe(CRITICAL.length) + expect(result.secondaryFixCount).toBe(SECONDARY.length) + expect(result.fixedSubscriptions).toBe(ALL.length) + expect(result.errorCount).toBe(0) + }) + + it('skips channel.chat.message subscriptions when the bot is banned', async () => { + state.isBanned = true + state.accountIds = ['111'] + seedSubscriptions('111', []) + + const result = await runSubscriptionHealthCheck() + + const subscribedTypes = state.subscribeCalls.map((c) => c.type) + expect(subscribedTypes).not.toContain('channel.chat.message') + expect(result.criticalFixCount).toBe(CRITICAL.length - 1) + }) + + it('counts errors when a subscription reports failure without throwing', async () => { + state.accountIds = ['111'] + seedSubscriptions('111', []) + state.subscribeResult = (_userId, type) => type !== 'stream.online' + + const result = await runSubscriptionHealthCheck() + + expect(result.errorCount).toBe(1) + expect(result.criticalFixCount).toBe(CRITICAL.length - 1) + }) + + it('aggregates thrown errors into userErrors', async () => { + state.accountIds = ['111'] + seedSubscriptions('111', []) + state.subscribeResult = () => { + throw new Error('twitch 500') + } + + const result = await runSubscriptionHealthCheck() + + expect(result.errorCount).toBe(ALL.length) + expect(result.userErrors['twitch 500']).toBe(ALL.length) + expect(result.fixedSubscriptions).toBe(0) + }) +}) diff --git a/packages/twitch-events/src/utils/socketUtils.ts b/packages/twitch-events/src/utils/socketUtils.ts index 41dab7943..167880a18 100644 --- a/packages/twitch-events/src/utils/socketUtils.ts +++ b/packages/twitch-events/src/utils/socketUtils.ts @@ -13,6 +13,7 @@ export const socketIo = new Server(5015, { // the socketio hooks onto the listener http server that it creates export const DOTABOD_EVENTS_ROOM = 'twitch-channel-events' export let eventsIOConnected = false +const connectedClients = new Set() /** * Sends the conduit ID to the requesting client @@ -49,31 +50,16 @@ export const setupSocketIO = () => { logger.info('[TWITCHEVENTS] Joining socket to room') await socket.join(DOTABOD_EVENTS_ROOM) - logger.info('[TWITCHEVENTS] eventsIOConnected = true') + // Track liveness by connected-client count so a reconnecting client's old + // socket disconnecting can't strand the flag false while a new one is live. + connectedClients.add(socket.id) eventsIOConnected = true - - socket.on('connect_error', (err) => { - logger.info(`[TWITCHEVENTS] socket connect_error due to ${err.message}`) - eventsIOConnected = false - }) + logger.info('[TWITCHEVENTS] client connected', { clients: connectedClients.size }) socket.on('disconnect', () => { - logger.info('[TWITCHEVENTS] Socket disconnected') - eventsIOConnected = false - }) - - socket.on('reconnect', (attemptNumber) => { - logger.info(`[TWITCHEVENTS] Socket reconnected on attempt ${attemptNumber}`) - eventsIOConnected = true - }) - - socket.on('reconnect_attempt', (attemptNumber) => { - logger.info(`[TWITCHEVENTS] Socket reconnect attempt ${attemptNumber}`) - }) - - socket.on('reconnect_failed', () => { - logger.info('[TWITCHEVENTS] Socket failed to reconnect') - eventsIOConnected = false + connectedClients.delete(socket.id) + eventsIOConnected = connectedClients.size > 0 + logger.info('[TWITCHEVENTS] Socket disconnected', { clients: connectedClients.size }) }) // Handle conduit data requests from twitch-chat